diff --git a/CHANGELOG.md b/CHANGELOG.md index fdcbf3346..c07c86b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - feat: Report a stereotype annotation such as `@Component` on an abstract class without a `@Lookup` method, which Spring does not register, with a quick fix that removes it (#601) - fix: Bean usages and the abstract-component gutter now list consumers declared in modules that depend on the bean's module, and count as injection points only the constructors Spring injects through: the `@Autowired` ones, otherwise the only constructor when it takes parameters, or a Kotlin primary constructor next to a no-arg secondary one, so the other constructors of a class with several and no `@Autowired` are no longer listed (#600) - fix: Type a bean declared by a superclass `@Bean` method and overridden with a narrower return type by the override's type, as Spring predicts it: the bean keeps the superclass declaration's name and annotations, and now autowires by the override's return type, in the bean model, the autowiring inspection and the Spring MCP bean tools (#594) +- fix: Complete and resolve `@Qualifier` bean names by the return type of a covariant `@Bean` override: a bean declared by a superclass `@Bean` method and overridden with a narrower return type in a configuration subclass is now offered for an injection point of the override's type, and its name resolves to the superclass `@Bean` declaration (#680) - feat: Report a composed `@Bean` annotation whose `name` attribute without an `@AliasFor` to `@Bean` is set at the use site to a value that conflicts with the name its meta-annotation fixes through `@Bean("…")`: on Spring Framework 6 such a configuration fails at startup with `AnnotationConfigurationException: Different @AliasFor mirror values` (#632) - fix: Read composed annotations as Spring merges them, honouring `@AliasFor` defaults, convention-named overrides before Spring 7 and the nearest meta-annotation chain, for method-level Spring MVC and Actuator mappings (a composed `@RequestMapping` whose `path` defaults to `/reports` now maps `/reports`), `@Bean` names, `@Profile`, `@Import` and `@SpringBootTest(classes)`, and the produces/consumes of the Spring MCP endpoint contract (#651) - fix: Evaluate `@ConditionalOnClass` and `@ConditionalOnMissingClass` on Kotlin beans like on Java ones: their values, class literals and class names alike, are read from the Kotlin source, so an unresolved class literal such as `@ConditionalOnClass(Missing::class)` now turns the bean off instead of being ignored (#496) diff --git a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/SpringBeanService.kt b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/SpringBeanService.kt index 88fa11c3f..4de58d9d5 100644 --- a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/SpringBeanService.kt +++ b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/SpringBeanService.kt @@ -38,13 +38,16 @@ class SpringBeanService { .filter { it.isMetaAnnotatedBy(SpringCoreClasses.COMPONENT) } .filter { inheritor -> excludedBeans.none { it.psiMember == inheritor } } .toSet() - val allBeansPsiMethods = searchServiceFacade.getComponentBeanPsiMethods(module) - .filterTo(mutableSetOf()) { psiMethod -> excludedBeans.none { it.psiMember == psiMethod } } - val beansPsiMethods = SpringSearchUtils - .getBeansPsiMethodsCheckMultipleBean(psiType, allBeansPsiMethods, beanPsiType).toSet() + val factoryMethods = searchServiceFacade.getComponentBeanFactoryMethods(module) + .filterKeys { psiMethod -> excludedBeans.none { it.psiMember == psiMethod } } + val matchingFactoryMethods = SpringSearchUtils + .getBeansPsiMethodsCheckMultipleBean(psiType, factoryMethods.values.toSet(), beanPsiType).toSet() + val beansPsiMethods = factoryMethods.filterValues { it in matchingFactoryMethods } val beanCandidatesByComponent = getBeanCandidatesInPsiModifierListOwner(module, classInheritors, SpringCoreClasses.COMPONENT) - val beanCandidatesByMethod = getBeanCandidatesInPsiModifierListOwner(module, beansPsiMethods, SpringCoreClasses.BEAN) + val beanCandidatesByMethod = getBeanCandidatesInPsiModifierListOwner( + module, beansPsiMethods.keys, SpringCoreClasses.BEAN, beansPsiMethods + ) var beanCandidatesByResolveClass: Set = setOf() if (beanPsiClass.isMetaAnnotatedBy(SpringCoreClasses.COMPONENT) @@ -58,12 +61,13 @@ class SpringBeanService { private fun getBeanCandidatesInPsiModifierListOwner( module: Module, owners: Set, - annotationName: String + annotationName: String, + factoryMethods: Map = emptyMap() ): Set { val beanCandidates = mutableSetOf() owners.forEach { owner -> val psiClass = if (owner is PsiMethod) { - owner.returnType?.resolveBeanPsiClass + (factoryMethods[owner] ?: owner).returnType?.resolveBeanPsiClass } else { owner.resolvePsiClass } diff --git a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/SpringSearchService.kt b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/SpringSearchService.kt index 79e5a361f..a09dd50d2 100644 --- a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/SpringSearchService.kt +++ b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/SpringSearchService.kt @@ -347,9 +347,12 @@ class SpringSearchService(private val project: Project) { @Deprecated("Don use directly. Use SpringSearchServiceFacade") fun getComponentBeanPsiMethods(module: Module): Set = getComponentBeanFactories(module).keys - private fun getComponentBeanFactories(module: Module): Map { + @Deprecated("Don use directly. Use SpringSearchServiceFacade") + fun getComponentBeanFactoryMethods(module: Module): Map = getComponentBeanFactories(module) + + private fun getComponentBeanFactories(module: Module): Map { return cachedValuesManager.getCachedValue(module) { - val factories = LinkedHashMap() + val factories = LinkedHashMap() getBeanPsiClassesAnnotatedByComponent(module) .asSequence() .map { it.psiClass } @@ -357,7 +360,7 @@ class SpringSearchService(private val project: Project) { .forEach { configuration -> getMethodBeans(configuration) .filter { isActive(it) } - .forEach { factories.putIfAbsent(it, configuration) } + .forEach { factory -> factories.getOrPut(factory) { effectiveFactory(configuration, factory) } } } CachedValueProvider.Result( factories, @@ -366,11 +369,14 @@ class SpringSearchService(private val project: Project) { } } + private fun effectiveFactory(configuration: PsiClass, factory: PsiMethod): PsiMethod = + BeanFactoryMethods.overrideIn(configuration, factory) ?: factory + private fun methodBeansOf( - configuration: PsiClass, factory: PsiMethod, beanClassOf: (PsiType) -> PsiClass? + factory: PsiMethod, effectiveFactory: PsiMethod, beanClassOf: (PsiType) -> PsiClass? ): Sequence { - val factoryOverride = BeanFactoryMethods.overrideIn(configuration, factory) - val beanClass = (factoryOverride ?: factory).returnType?.let(beanClassOf) ?: return emptySequence() + val beanClass = effectiveFactory.returnType?.let(beanClassOf) ?: return emptySequence() + val factoryOverride = effectiveFactory.takeUnless { it == factory } return factory.resolveBeanName.asSequence() .map { PsiBean(it, beanClass, factory.getQualifierAnnotation(), factory, factoryOverride = factoryOverride) } } @@ -401,7 +407,7 @@ class SpringSearchService(private val project: Project) { private fun searchComponentPsiClassesByBeanMethods(module: Module): Set { return getComponentBeanFactories(module).asSequence() - .flatMap { (factory, configuration) -> methodBeansOf(configuration, factory) { it.resolvedPsiClass } } + .flatMap { (factory, effectiveFactory) -> methodBeansOf(factory, effectiveFactory) { it.resolvedPsiClass } } .filter { SpringCoreUtil.isSpringBeanCandidateClass(it.psiClass) } .toSet() } @@ -413,7 +419,9 @@ class SpringSearchService(private val project: Project) { .flatMap { configuration -> BeanFactoryMethods.of(configuration) .filter { isActive(it) } - .flatMap { factory -> methodBeansOf(configuration, factory) { it.resolvedPsiClass } } + .flatMap { factory -> + methodBeansOf(factory, effectiveFactory(configuration, factory)) { it.resolvedPsiClass } + } } .filter { SpringCoreUtil.isSpringBeanCandidateClass(it.psiClass) } .toSet() @@ -421,7 +429,9 @@ class SpringSearchService(private val project: Project) { fun searchArrayComponentPsiClassesByBeanMethods(module: Module): Set { return getComponentBeanFactories(module).asSequence() - .flatMap { (factory, configuration) -> methodBeansOf(configuration, factory) { it.resolvedDeepPsiClass } } + .flatMap { (factory, effectiveFactory) -> + methodBeansOf(factory, effectiveFactory) { it.resolvedDeepPsiClass } + } .filter { it.factoryMethod?.returnType is PsiArrayType } .filter { SpringCoreUtil.isSpringBeanCandidateClass(it.psiClass) } .toSet() @@ -487,8 +497,7 @@ class SpringSearchService(private val project: Project) { fun PsiType.isMultipleBean(module: Module): Boolean { if (!possibleMultipleBean()) return false val beanPsiType = beanPsiType ?: return true - val methodsPsiBeans = getComponentBeanFactories(module) - .map { (factory, configuration) -> BeanFactoryMethods.overrideIn(configuration, factory) ?: factory } + val methodsPsiBeans = getComponentBeanFactories(module).values val byExactMatch = methodsPsiBeans.filterByExactMatch(this) if (byExactMatch.isEmpty()) return true diff --git a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/SpringSearchServiceFacade.kt b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/SpringSearchServiceFacade.kt index 1af016acc..461abf541 100644 --- a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/SpringSearchServiceFacade.kt +++ b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/SpringSearchServiceFacade.kt @@ -89,11 +89,11 @@ class SpringSearchServiceFacade(private val project: Project) { } } - fun getComponentBeanPsiMethods(module: Module): Set { + fun getComponentBeanFactoryMethods(module: Module): Map { return if (isExternalProjectExist(project)) { - nativeSearchService.getBeanPsiMethods() + nativeSearchService.getBeanPsiMethods().associateWith { it } } else { - springSearchService.getComponentBeanPsiMethods(module) + springSearchService.getComponentBeanFactoryMethods(module) } } diff --git a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/util/BeanFactoryMethods.kt b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/util/BeanFactoryMethods.kt index 7de6160b7..c66715328 100644 --- a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/util/BeanFactoryMethods.kt +++ b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/util/BeanFactoryMethods.kt @@ -39,7 +39,10 @@ object BeanFactoryMethods { return methods.asSequence() } - fun overrideIn(configuration: PsiClass, factory: PsiMethod): PsiMethod? = - MethodSignatureUtil.findMethodBySuperMethod(configuration, factory, true) - ?.takeUnless { factory.manager.areElementsEquivalent(it, factory) } + fun overrideIn(configuration: PsiClass, factory: PsiMethod): PsiMethod? { + val manager = factory.manager + if (manager.areElementsEquivalent(factory.containingClass, configuration)) return null + return MethodSignatureUtil.findMethodBySuperMethod(configuration, factory, true) + ?.takeUnless { manager.areElementsEquivalent(it, factory) } + } } diff --git a/modules/spring-core/src/test/kotlin/com/explyt/spring/core/reference/java/CovariantOverrideQualifierReferenceTest.kt b/modules/spring-core/src/test/kotlin/com/explyt/spring/core/reference/java/CovariantOverrideQualifierReferenceTest.kt new file mode 100644 index 000000000..9a521393e --- /dev/null +++ b/modules/spring-core/src/test/kotlin/com/explyt/spring/core/reference/java/CovariantOverrideQualifierReferenceTest.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026 Explyt Ltd + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.explyt.spring.core.reference.java + +import com.explyt.spring.core.references.CompleteBeanReference +import com.explyt.spring.core.service.PsiBean +import com.explyt.spring.core.service.SpringSearchServiceFacade +import com.explyt.spring.test.ExplytJavaLightTestCase +import com.explyt.spring.test.TestLibrary +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.openapi.util.registry.Registry +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiLanguageInjectionHost +import com.intellij.psi.PsiMethod +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.util.PsiTreeUtil + +class CovariantOverrideQualifierReferenceTest : ExplytJavaLightTestCase() { + override val libraries = arrayOf(TestLibrary.springContext_6_0_7, TestLibrary.springBootAutoConfigure_3_1_1) + + override fun setUp() { + super.setUp() + Registry.get("explyt.spring.root.runConfiguration").setValue(false, testRootDisposable) + } + + fun testCompletionOffersCovariantOverrideBeanByOverrideType() { + configureCovariantOverride() + configureConsumer("Dog", "") + assertEquals(setOf("animal"), qualifierReference().variantNames()) + } + + fun testQualifierResolvesCovariantOverrideBeanByOverrideType() { + val bean = configureCovariantOverride() + configureConsumer("Dog", "animal") + assertResolvesTo(bean.psiMember, qualifierReference().resolve()) + } + + fun testCompletionOffersCovariantOverrideBeanBySuperclassType() { + configureCovariantOverride() + configureConsumer("Animal", "") + assertEquals(setOf("animal"), qualifierReference().variantNames()) + } + + fun testQualifierResolvesCovariantOverrideBeanBySuperclassType() { + val bean = configureCovariantOverride() + configureConsumer("Animal", "animal") + assertResolvesTo(bean.psiMember, qualifierReference().resolve()) + } + + fun testCompletionOffersBeanDeclaredWithNarrowType() { + configureNarrowFactory() + configureConsumer("Dog", "") + assertEquals(setOf("animal"), qualifierReference().variantNames()) + } + + fun testQualifierResolvesBeanDeclaredWithNarrowType() { + val bean = configureNarrowFactory() + configureConsumer("Dog", "animal") + assertResolvesTo(bean.psiMember, qualifierReference().resolve()) + } + + private fun configureCovariantOverride(): PsiBean { + configureConfiguration( + base = "class Base { @Bean public Animal animal() { return new Animal(); } }", + body = "@Override public Dog animal() { return new Dog(); }" + ) + val base = animalMethodOf("Base") + val child = animalMethodOf("Child") + assertEquals("com.explyt.demo.Animal", base.returnType?.canonicalText) + assertEquals("Precondition: override narrows the return type", "com.explyt.demo.Dog", child.returnType?.canonicalText) + assertTrue("Precondition: Child overrides Base.animal", child.findSuperMethods().contains(base)) + return activeAnimalBean() + } + + private fun configureNarrowFactory(): PsiBean { + configureConfiguration( + base = "class Base { @Bean public Dog animal() { return new Dog(); } }", + body = "" + ) + assertEquals("Precondition: Child declares no override", 0, psiClass("Child").findMethodsByName("animal", false).size) + assertEquals("com.explyt.demo.Dog", animalMethodOf("Base").returnType?.canonicalText) + return activeAnimalBean() + } + + private fun configureConfiguration(base: String, body: String) { + myFixture.addFileToProject( + "com/explyt/demo/App.java", + "package com.explyt.demo; @org.springframework.boot.autoconfigure.SpringBootApplication public class App {}" + ) + myFixture.addFileToProject( + "com/explyt/demo/Child.java", + """ + package com.explyt.demo; + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + class Animal {} + class Dog extends Animal {} + $base + @Configuration(proxyBeanMethods = false) + public class Child extends Base { + $body + } + """.trimIndent() + ) + } + + private fun configureConsumer(type: String, qualifier: String) { + myFixture.configureByText( + "Consumer.java", + """ + package com.explyt.demo; + import org.springframework.stereotype.Component; + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.beans.factory.annotation.Qualifier; + @Component + public class Consumer { + @Autowired @Qualifier("$qualifier") $type injected; + } + """.trimIndent() + ) + assertEquals("com.explyt.demo.$type", psiClass("Consumer").findFieldByName("injected", false)?.type?.canonicalText) + } + + private fun activeAnimalBean(): PsiBean { + val beans = SpringSearchServiceFacade.getInstance(project).getAllActiveBeans(module).filter { it.name == "animal" } + assertEquals("Precondition: the bean model holds one animal bean", 1, beans.size) + assertEquals( + "Precondition: the bean model types animal as Dog", + "com.explyt.demo.Dog", + beans.single().psiClass.qualifiedName + ) + return beans.single() + } + + private fun qualifierReference(): CompleteBeanReference { + val leaf = myFixture.file.findElementAt(myFixture.caretOffset) + val host = PsiTreeUtil.getParentOfType(leaf, PsiLanguageInjectionHost::class.java, false) + ?: error("No string literal at the caret") + return host.references.filterIsInstance().singleOrNull() + ?: error("No qualifier bean reference on ${host.text}: ${host.references.toList()}") + } + + private fun CompleteBeanReference.variantNames(): Set = + variants.map { (it as LookupElement).lookupString }.toSet() + + private fun assertResolvesTo(expected: PsiElement, actual: PsiElement?) { + assertTrue( + "Qualifier must resolve to ${describe(expected)}, got ${describe(actual)}", + actual != null && psiManager.areElementsEquivalent(expected, actual) + ) + } + + private fun describe(element: PsiElement?): String = + (element as? PsiMethod)?.let { "${it.containingClass?.name}.${it.name}" } ?: element.toString() + + private fun animalMethodOf(className: String): PsiMethod = + psiClass(className).findMethodsByName("animal", false).single() + + private fun psiClass(name: String) = JavaPsiFacade.getInstance(project) + .findClass("com.explyt.demo.$name", GlobalSearchScope.projectScope(project)) ?: error("No PSI for $name") +} diff --git a/modules/spring-core/src/test/kotlin/com/explyt/spring/core/reference/kotlin/CovariantOverrideQualifierReferenceTest.kt b/modules/spring-core/src/test/kotlin/com/explyt/spring/core/reference/kotlin/CovariantOverrideQualifierReferenceTest.kt new file mode 100644 index 000000000..cd6773944 --- /dev/null +++ b/modules/spring-core/src/test/kotlin/com/explyt/spring/core/reference/kotlin/CovariantOverrideQualifierReferenceTest.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026 Explyt Ltd + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.explyt.spring.core.reference.kotlin + +import com.explyt.spring.core.references.CompleteBeanReference +import com.explyt.spring.core.service.PsiBean +import com.explyt.spring.core.service.SpringSearchServiceFacade +import com.explyt.spring.test.ExplytKotlinLightTestCase +import com.explyt.spring.test.TestLibrary +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.openapi.util.registry.Registry +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiLanguageInjectionHost +import com.intellij.psi.PsiMethod +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.util.PsiTreeUtil + +class CovariantOverrideQualifierReferenceTest : ExplytKotlinLightTestCase() { + override val libraries = arrayOf(TestLibrary.springContext_6_0_7, TestLibrary.springBootAutoConfigure_3_1_1) + + override fun setUp() { + super.setUp() + Registry.get("explyt.spring.root.runConfiguration").setValue(false, testRootDisposable) + } + + fun testCompletionOffersCovariantOverrideBeanByOverrideType() { + configureCovariantOverride() + configureConsumer("Dog", "") + assertEquals(setOf("animal"), qualifierReference().variantNames()) + } + + fun testQualifierResolvesCovariantOverrideBeanByOverrideType() { + val bean = configureCovariantOverride() + configureConsumer("Dog", "animal") + assertResolvesTo(bean.psiMember, qualifierReference().resolve()) + } + + fun testCompletionOffersCovariantOverrideBeanBySuperclassType() { + configureCovariantOverride() + configureConsumer("Animal", "") + assertEquals(setOf("animal"), qualifierReference().variantNames()) + } + + fun testQualifierResolvesCovariantOverrideBeanBySuperclassType() { + val bean = configureCovariantOverride() + configureConsumer("Animal", "animal") + assertResolvesTo(bean.psiMember, qualifierReference().resolve()) + } + + fun testCompletionOffersBeanDeclaredWithNarrowType() { + configureNarrowFactory() + configureConsumer("Dog", "") + assertEquals(setOf("animal"), qualifierReference().variantNames()) + } + + fun testQualifierResolvesBeanDeclaredWithNarrowType() { + val bean = configureNarrowFactory() + configureConsumer("Dog", "animal") + assertResolvesTo(bean.psiMember, qualifierReference().resolve()) + } + + private fun configureCovariantOverride(): PsiBean { + configureConfiguration( + base = "open class Base { @Bean open fun animal(): Animal = Animal() }", + body = "override fun animal(): Dog = Dog()" + ) + val base = animalMethodOf("Base") + val child = animalMethodOf("Child") + assertEquals("com.explyt.demo.Animal", base.returnType?.canonicalText) + assertEquals("Precondition: override narrows the return type", "com.explyt.demo.Dog", child.returnType?.canonicalText) + assertTrue("Precondition: Child overrides Base.animal", child.findSuperMethods().contains(base)) + return activeAnimalBean() + } + + private fun configureNarrowFactory(): PsiBean { + configureConfiguration( + base = "open class Base { @Bean open fun animal(): Dog = Dog() }", + body = "" + ) + assertEquals("Precondition: Child declares no override", 0, psiClass("Child").findMethodsByName("animal", false).size) + assertEquals("com.explyt.demo.Dog", animalMethodOf("Base").returnType?.canonicalText) + return activeAnimalBean() + } + + private fun configureConfiguration(base: String, body: String) { + myFixture.addFileToProject( + "com/explyt/demo/App.kt", + "package com.explyt.demo\n@org.springframework.boot.autoconfigure.SpringBootApplication(proxyBeanMethods = false) class App" + ) + myFixture.addFileToProject( + "com/explyt/demo/Child.kt", + """ + package com.explyt.demo + import org.springframework.context.annotation.Bean + import org.springframework.context.annotation.Configuration + open class Animal + class Dog : Animal() + $base + @Configuration(proxyBeanMethods = false) + open class Child : Base() { + $body + } + """.trimIndent() + ) + } + + private fun configureConsumer(type: String, qualifier: String) { + myFixture.configureByText( + "Consumer.java", + """ + package com.explyt.demo; + import org.springframework.stereotype.Component; + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.beans.factory.annotation.Qualifier; + @Component + public class Consumer { + @Autowired @Qualifier("$qualifier") $type injected; + } + """.trimIndent() + ) + assertEquals("com.explyt.demo.$type", psiClass("Consumer").findFieldByName("injected", false)?.type?.canonicalText) + } + + private fun activeAnimalBean(): PsiBean { + val beans = SpringSearchServiceFacade.getInstance(project).getAllActiveBeans(module).filter { it.name == "animal" } + assertEquals("Precondition: the bean model holds one animal bean", 1, beans.size) + assertEquals( + "Precondition: the bean model types animal as Dog", + "com.explyt.demo.Dog", + beans.single().psiClass.qualifiedName + ) + return beans.single() + } + + private fun qualifierReference(): CompleteBeanReference { + val leaf = myFixture.file.findElementAt(myFixture.caretOffset) + val host = PsiTreeUtil.getParentOfType(leaf, PsiLanguageInjectionHost::class.java, false) + ?: error("No string literal at the caret") + return host.references.filterIsInstance().singleOrNull() + ?: error("No qualifier bean reference on ${host.text}: ${host.references.toList()}") + } + + private fun CompleteBeanReference.variantNames(): Set = + variants.map { (it as LookupElement).lookupString }.toSet() + + private fun assertResolvesTo(expected: PsiElement, actual: PsiElement?) { + assertTrue( + "Qualifier must resolve to ${describe(expected)}, got ${describe(actual)}", + actual != null && psiManager.areElementsEquivalent(expected, actual) + ) + } + + private fun describe(element: PsiElement?): String = + (element as? PsiMethod)?.let { "${it.containingClass?.name}.${it.name}" } ?: element.toString() + + private fun animalMethodOf(className: String): PsiMethod = + psiClass(className).findMethodsByName("animal", false).single() + + private fun psiClass(name: String) = JavaPsiFacade.getInstance(project) + .findClass("com.explyt.demo.$name", GlobalSearchScope.projectScope(project)) ?: error("No PSI for $name") +} diff --git a/modules/spring-core/src/test/kotlin/com/explyt/spring/core/service/HierarchySearchDecoy.kt b/modules/spring-core/src/test/kotlin/com/explyt/spring/core/service/HierarchySearchDecoy.kt new file mode 100644 index 000000000..37e5ff4f3 --- /dev/null +++ b/modules/spring-core/src/test/kotlin/com/explyt/spring/core/service/HierarchySearchDecoy.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Explyt Ltd + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.explyt.spring.core.service + +import com.intellij.openapi.util.Pair +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiSubstitutor +import com.intellij.psi.impl.light.LightClass +import com.intellij.psi.impl.light.LightMethod + +class HierarchySearchDecoy(configuration: PsiClass) : LightClass(configuration) { + + fun declare(method: PsiMethod): PsiMethod = LightMethod(manager, method, this) + + override fun findMethodsAndTheirSubstitutorsByName( + name: String, + checkBases: Boolean + ): List> = + if (checkBases) searched() else super.findMethodsAndTheirSubstitutorsByName(name, false) + + override fun findMethodsByName(name: String?, checkBases: Boolean): Array = + if (checkBases) searched() else super.findMethodsByName(name, false) + + override fun findMethodsBySignature(patternMethod: PsiMethod, checkBases: Boolean): Array = + if (checkBases) searched() else super.findMethodsBySignature(patternMethod, false) + + override fun findMethodBySignature(patternMethod: PsiMethod, checkBases: Boolean): PsiMethod? = + if (checkBases) searched() else super.findMethodBySignature(patternMethod, false) + + override fun getAllMethods(): Array = searched() + + override fun getAllMethodsAndTheirSubstitutors(): List> = searched() + + private fun searched(): Nothing = error("A factory declared in $name must not search the hierarchy of $name") +} diff --git a/modules/spring-core/src/test/kotlin/com/explyt/spring/core/service/java/BeanFactoryMethodsTest.kt b/modules/spring-core/src/test/kotlin/com/explyt/spring/core/service/java/BeanFactoryMethodsTest.kt index a6d12fdba..dc1eac05d 100644 --- a/modules/spring-core/src/test/kotlin/com/explyt/spring/core/service/java/BeanFactoryMethodsTest.kt +++ b/modules/spring-core/src/test/kotlin/com/explyt/spring/core/service/java/BeanFactoryMethodsTest.kt @@ -7,6 +7,7 @@ package com.explyt.spring.core.service.java import com.explyt.spring.core.SpringCoreClasses import com.explyt.spring.core.inspections.SpringBeanIncorrectAutowiringInspection +import com.explyt.spring.core.service.HierarchySearchDecoy import com.explyt.spring.core.service.SpringSearchServiceFacade import com.explyt.spring.core.service.SpringSearchService import com.explyt.spring.core.service.beans.BeanModelSource @@ -83,6 +84,70 @@ class BeanFactoryMethodsTest : ExplytJavaLightTestCase() { assertAutowiringHighlights("Animal", "animal") } + fun testEffectiveFactoryMapIsCachedWhilePsiIsUnchanged() { + configureCovariantOverride() + val facade = SpringSearchServiceFacade.getInstance(project) + val first = facade.getComponentBeanFactoryMethods(module) + assertAnimalTypedByCovariantOverride(first) + assertSame( + "Without a PSI change the effective factory map must come from the cached model", + first, + facade.getComponentBeanFactoryMethods(module) + ) + } + + fun testEffectiveFactoryMapFollowsAnAddedConfiguration() { + configureCovariantOverride() + val facade = SpringSearchServiceFacade.getInstance(project) + val before = facade.getComponentBeanFactoryMethods(module) + assertAnimalTypedByCovariantOverride(before) + assertTrue("Precondition: no cat factory yet", before.keys.none { it.name == "cat" }) + myFixture.addFileToProject( + "com/explyt/demo/Extra.java", + """ + package com.explyt.demo; + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + class Cat {} + @Configuration(proxyBeanMethods = false) + public class Extra { + @Bean public Cat cat() { return new Cat(); } + } + """.trimIndent() + ) + val after = facade.getComponentBeanFactoryMethods(module) + assertNotSame("An added configuration must recompute the effective factory map", before, after) + assertAnimalTypedByCovariantOverride(after) + val cat = after.entries.single { it.key.name == "cat" } + assertEquals("com.explyt.demo.Extra", cat.key.containingClass?.qualifiedName) + assertEquals("com.explyt.demo.Extra", cat.value.containingClass?.qualifiedName) + } + + fun testOverrideInDoesNotSearchTheHierarchyOfTheDeclaringConfiguration() { + configure("@Bean public Foo foo() { return new Foo(); }") + val factory = psiClass("Child").findMethodsByName("foo", false).single() + assertTrue("Precondition: Child declares the @Bean factory", factory.hasAnnotation(SpringCoreClasses.BEAN)) + val configuration = HierarchySearchDecoy(psiClass("Child")) + val declaredFactory = configuration.declare(factory) + assertSame("Precondition: the factory is declared in the configuration", configuration, declaredFactory.containingClass) + assertNull(BeanFactoryMethods.overrideIn(configuration, declaredFactory)) + } + + fun testOverrideInFindsTheCovariantOverrideOfAnInheritedFactory() { + configureCovariantOverride() + val inherited = psiClass("Base").findMethodsByName("animal", false).single() + val override = BeanFactoryMethods.overrideIn(psiClass("Child"), inherited) + assertEquals("com.explyt.demo.Child", override?.containingClass?.qualifiedName) + assertEquals("com.explyt.demo.Dog", override?.returnType?.canonicalText) + } + + private fun assertAnimalTypedByCovariantOverride(factories: Map) { + val animal = factories.entries.single { it.key.name == "animal" } + assertEquals("com.explyt.demo.Base", animal.key.containingClass?.qualifiedName) + assertEquals("com.explyt.demo.Child", animal.value.containingClass?.qualifiedName) + assertEquals("com.explyt.demo.Dog", animal.value.returnType?.canonicalText) + } + private fun configureCovariantOverride() { configure( "@Override public Dog animal() { return new Dog(); }", diff --git a/modules/spring-core/src/test/kotlin/com/explyt/spring/core/service/kotlin/BeanFactoryMethodsTest.kt b/modules/spring-core/src/test/kotlin/com/explyt/spring/core/service/kotlin/BeanFactoryMethodsTest.kt index cdb24051b..2c20ade8b 100644 --- a/modules/spring-core/src/test/kotlin/com/explyt/spring/core/service/kotlin/BeanFactoryMethodsTest.kt +++ b/modules/spring-core/src/test/kotlin/com/explyt/spring/core/service/kotlin/BeanFactoryMethodsTest.kt @@ -7,6 +7,7 @@ package com.explyt.spring.core.service.kotlin import com.explyt.spring.core.SpringCoreClasses import com.explyt.spring.core.inspections.SpringBeanIncorrectAutowiringInspection +import com.explyt.spring.core.service.HierarchySearchDecoy import com.explyt.spring.core.service.SpringSearchServiceFacade import com.explyt.spring.core.service.SpringSearchService import com.explyt.spring.core.service.beans.BeanModelSource @@ -76,6 +77,70 @@ class BeanFactoryMethodsTest : ExplytKotlinLightTestCase() { assertAutowiringHighlights("Animal", "animal") } + fun testEffectiveFactoryMapIsCachedWhilePsiIsUnchanged() { + configureCovariantOverride() + val facade = SpringSearchServiceFacade.getInstance(project) + val first = facade.getComponentBeanFactoryMethods(module) + assertAnimalTypedByCovariantOverride(first) + assertSame( + "Without a PSI change the effective factory map must come from the cached model", + first, + facade.getComponentBeanFactoryMethods(module) + ) + } + + fun testEffectiveFactoryMapFollowsAnAddedConfiguration() { + configureCovariantOverride() + val facade = SpringSearchServiceFacade.getInstance(project) + val before = facade.getComponentBeanFactoryMethods(module) + assertAnimalTypedByCovariantOverride(before) + assertTrue("Precondition: no cat factory yet", before.keys.none { it.name == "cat" }) + myFixture.addFileToProject( + "com/explyt/demo/Extra.kt", + """ + package com.explyt.demo + import org.springframework.context.annotation.Bean + import org.springframework.context.annotation.Configuration + class Cat + @Configuration(proxyBeanMethods = false) + class Extra { + @Bean fun cat(): Cat = Cat() + } + """.trimIndent() + ) + val after = facade.getComponentBeanFactoryMethods(module) + assertNotSame("An added configuration must recompute the effective factory map", before, after) + assertAnimalTypedByCovariantOverride(after) + val cat = after.entries.single { it.key.name == "cat" } + assertEquals("com.explyt.demo.Extra", cat.key.containingClass?.qualifiedName) + assertEquals("com.explyt.demo.Extra", cat.value.containingClass?.qualifiedName) + } + + fun testOverrideInDoesNotSearchTheHierarchyOfTheDeclaringConfiguration() { + configure("@Bean fun foo(): Foo = Foo()") + val factory = psiClass("Child").findMethodsByName("foo", false).single() + assertTrue("Precondition: Child declares the @Bean factory", factory.hasAnnotation(SpringCoreClasses.BEAN)) + val configuration = HierarchySearchDecoy(psiClass("Child")) + val declaredFactory = configuration.declare(factory) + assertSame("Precondition: the factory is declared in the configuration", configuration, declaredFactory.containingClass) + assertNull(BeanFactoryMethods.overrideIn(configuration, declaredFactory)) + } + + fun testOverrideInFindsTheCovariantOverrideOfAnInheritedFactory() { + configureCovariantOverride() + val inherited = psiClass("Base").findMethodsByName("animal", false).single() + val override = BeanFactoryMethods.overrideIn(psiClass("Child"), inherited) + assertEquals("com.explyt.demo.Child", override?.containingClass?.qualifiedName) + assertEquals("com.explyt.demo.Dog", override?.returnType?.canonicalText) + } + + private fun assertAnimalTypedByCovariantOverride(factories: Map) { + val animal = factories.entries.single { it.key.name == "animal" } + assertEquals("com.explyt.demo.Base", animal.key.containingClass?.qualifiedName) + assertEquals("com.explyt.demo.Child", animal.value.containingClass?.qualifiedName) + assertEquals("com.explyt.demo.Dog", animal.value.returnType?.canonicalText) + } + private fun configureCovariantOverride() { configure( "override fun animal(): Dog = Dog()",