Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PsiBean> = setOf()
if (beanPsiClass.isMetaAnnotatedBy(SpringCoreClasses.COMPONENT)
Expand All @@ -58,12 +61,13 @@ class SpringBeanService {
private fun getBeanCandidatesInPsiModifierListOwner(
module: Module,
owners: Set<PsiMember>,
annotationName: String
annotationName: String,
factoryMethods: Map<PsiMethod, PsiMethod> = emptyMap()
): Set<PsiBean> {
val beanCandidates = mutableSetOf<PsiBean>()
owners.forEach { owner ->
val psiClass = if (owner is PsiMethod) {
owner.returnType?.resolveBeanPsiClass
(factoryMethods[owner] ?: owner).returnType?.resolveBeanPsiClass
} else {
owner.resolvePsiClass
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,17 +347,20 @@ class SpringSearchService(private val project: Project) {
@Deprecated("Don use directly. Use SpringSearchServiceFacade")
fun getComponentBeanPsiMethods(module: Module): Set<PsiMethod> = getComponentBeanFactories(module).keys

private fun getComponentBeanFactories(module: Module): Map<PsiMethod, PsiClass> {
@Deprecated("Don use directly. Use SpringSearchServiceFacade")
fun getComponentBeanFactoryMethods(module: Module): Map<PsiMethod, PsiMethod> = getComponentBeanFactories(module)

private fun getComponentBeanFactories(module: Module): Map<PsiMethod, PsiMethod> {
return cachedValuesManager.getCachedValue(module) {
val factories = LinkedHashMap<PsiMethod, PsiClass>()
val factories = LinkedHashMap<PsiMethod, PsiMethod>()
getBeanPsiClassesAnnotatedByComponent(module)
.asSequence()
.map { it.psiClass }
.filter { it.isValid }
.forEach { configuration ->
getMethodBeans(configuration)
.filter { isActive(it) }
.forEach { factories.putIfAbsent(it, configuration) }
.forEach { factory -> factories.getOrPut(factory) { effectiveFactory(configuration, factory) } }
}
CachedValueProvider.Result(
factories,
Expand All @@ -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<PsiBean> {
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) }
}
Expand Down Expand Up @@ -401,7 +407,7 @@ class SpringSearchService(private val project: Project) {

private fun searchComponentPsiClassesByBeanMethods(module: Module): Set<PsiBean> {
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()
}
Expand All @@ -413,15 +419,19 @@ 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()
}

fun searchArrayComponentPsiClassesByBeanMethods(module: Module): Set<PsiBean> {
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()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ class SpringSearchServiceFacade(private val project: Project) {
}
}

fun getComponentBeanPsiMethods(module: Module): Set<PsiMethod> {
fun getComponentBeanFactoryMethods(module: Module): Map<PsiMethod, PsiMethod> {
return if (isExternalProjectExist(project)) {
nativeSearchService.getBeanPsiMethods()
nativeSearchService.getBeanPsiMethods().associateWith { it }
} else {
springSearchService.getComponentBeanPsiMethods(module)
springSearchService.getComponentBeanFactoryMethods(module)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
Original file line number Diff line number Diff line change
@@ -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", "<caret>")
assertEquals(setOf("animal"), qualifierReference().variantNames())
}

fun testQualifierResolvesCovariantOverrideBeanByOverrideType() {
val bean = configureCovariantOverride()
configureConsumer("Dog", "anim<caret>al")
assertResolvesTo(bean.psiMember, qualifierReference().resolve())
}

fun testCompletionOffersCovariantOverrideBeanBySuperclassType() {
configureCovariantOverride()
configureConsumer("Animal", "<caret>")
assertEquals(setOf("animal"), qualifierReference().variantNames())
}

fun testQualifierResolvesCovariantOverrideBeanBySuperclassType() {
val bean = configureCovariantOverride()
configureConsumer("Animal", "anim<caret>al")
assertResolvesTo(bean.psiMember, qualifierReference().resolve())
}

fun testCompletionOffersBeanDeclaredWithNarrowType() {
configureNarrowFactory()
configureConsumer("Dog", "<caret>")
assertEquals(setOf("animal"), qualifierReference().variantNames())
}

fun testQualifierResolvesBeanDeclaredWithNarrowType() {
val bean = configureNarrowFactory()
configureConsumer("Dog", "anim<caret>al")
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<CompleteBeanReference>().singleOrNull()
?: error("No qualifier bean reference on ${host.text}: ${host.references.toList()}")
}

private fun CompleteBeanReference.variantNames(): Set<String> =
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")
}
Loading
Loading