diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java index c3564122088..dbcb999ae04 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java @@ -16,7 +16,6 @@ */ package org.sonar.java.model.springcontext; -import java.beans.Introspector; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; @@ -105,8 +104,7 @@ public void visitNode(Tree tree) { String pkg = PackageUtils.packageNameOf(classTree.symbol()); if (SpringUtils.STEREOTYPE_ANNOTATIONS.stream().anyMatch(meta::isAnnotatedWith)) { - String beanName = extractBeanName(meta) - .orElseGet(() -> defaultBeanName(classTree.simpleName().name())); + String beanName = SpringUtils.resolveStereotypeBeanName(meta, classTree.simpleName().name()); List deps = collectAutowiredDependencies(classTree); // Class-level bean (stereotype annotations) collectedBeans.add(new BeanData( @@ -229,43 +227,9 @@ private static BeanData deserializeBean(String line, InputFile inputFile) { return new BeanData(beanName, type, beanPackage, inputFile, textSpan, isPrimary, deps); } - private static Optional extractBeanName(SymbolMetadata meta) { - for (String annotation : SpringUtils.STEREOTYPE_ANNOTATIONS) { - List attrs = meta.valuesForAnnotation(annotation); - if (attrs != null) { - Optional name = attrs.stream() - .filter(v -> "value".equals(v.name()) || "name".equals(v.name())) - .map(v -> (String) v.value()) - .filter(s -> !s.isBlank()) - .findFirst(); - if (name.isPresent()) { - return name; - } - } - } - return Optional.empty(); - } - - private static String defaultBeanName(String simpleName) { - return Introspector.decapitalize(simpleName); - } - private void collectBeanMethod(MethodTree method, String pkg) { SymbolMetadata beanMeta = method.symbol().metadata(); - List attrs = beanMeta.valuesForAnnotation(SpringUtils.BEAN_ANNOTATION); - String beanName = Optional.ofNullable(attrs) - .flatMap(list -> list.stream() - .filter(v -> "value".equals(v.name()) || "name".equals(v.name())) - .map(v -> { - Object val = v.value(); - if (val instanceof Object[] arr && arr.length > 0) { - return (String) arr[0]; - } - return val instanceof String s ? s : null; - }) - .filter(s -> s != null && !s.isBlank()) - .findFirst()) - .orElseGet(() -> method.simpleName().name()); + String beanName = SpringUtils.resolveBeanMethodName(method); String returnTypeFqn = method.returnType() != null ? method.returnType().symbolType().fullyQualifiedName() diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModelGatherers.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModelGatherers.java index b3b178cdbf1..257f9e200ac 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModelGatherers.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModelGatherers.java @@ -40,7 +40,8 @@ private SpringContextModelGatherers() { public static List getAllGatherers() { return List.of( new ComponentScanPackageGatherer(), - new BeanDefinitionGatherer() + new BeanDefinitionGatherer(), + new TypeToBeanNamesIndexGatherer() ); } diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndex.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndex.java index b21e6e561af..ac054f4bfe6 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndex.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndex.java @@ -53,4 +53,4 @@ public void addBeanForType(String beanType, String beanName) { public Set getNamesForType(String beanType) { return Collections.unmodifiableSet(beanNamesByType.getOrDefault(beanType, Set.of())); } -} \ No newline at end of file +} diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java new file mode 100644 index 00000000000..268f06e5770 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java @@ -0,0 +1,98 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model.springcontext; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.sonar.java.utils.SpringUtils; +import org.sonar.plugins.java.api.ModuleScannerContext; +import org.sonar.plugins.java.api.semantic.Symbol; +import org.sonar.plugins.java.api.semantic.Type; +import org.sonar.plugins.java.api.tree.ClassTree; +import org.sonar.plugins.java.api.tree.MethodTree; +import org.sonar.plugins.java.api.tree.Tree; + +/** + * Populates {@link TypeToBeanNamesIndex} by mapping every type in a bean's hierarchy + * (concrete class, superclasses, interfaces) to the bean's name. + */ +public class TypeToBeanNamesIndexGatherer extends SpringContextModelGatherer { + + private record BeanTypeEntry(String beanName, Set typeHierarchy) {} + + private final List collectedEntries = new ArrayList<>(); + + @Override + public List nodesToVisit() { + return List.of(Tree.Kind.CLASS); + } + + @Override + public void visitNode(Tree tree) { + ClassTree classTree = (ClassTree) tree; + if (classTree.simpleName() == null) { + return; + } + + var meta = classTree.symbol().metadata(); + if (SpringUtils.STEREOTYPE_ANNOTATIONS.stream().anyMatch(meta::isAnnotatedWith)) { + String beanName = SpringUtils.resolveStereotypeBeanName(meta, classTree.simpleName().name()); + collectedEntries.add(new BeanTypeEntry(beanName, collectTypeHierarchy(classTree.symbol()))); + + for (MethodTree method : SpringUtils.getBeanMethods(classTree)) { + Set typeHierarchy = collectTypeHierarchy(method.returnType().symbolType().symbol()); + for (String methodBeanName : SpringUtils.resolveBeanMethodNames(method)) { + collectedEntries.add(new BeanTypeEntry(methodBeanName, typeHierarchy)); + } + } + } + } + + @Override + public void gatherSpringContextData(ModuleScannerContext context, SpringContextModel springContextModel) { + TypeToBeanNamesIndex index = springContextModel.getTypeToBeanNamesIndex(); + for (BeanTypeEntry entry : collectedEntries) { + for (String typeFqn : entry.typeHierarchy()) { + index.addBeanForType(typeFqn, entry.beanName()); + } + } + } + + private static Set collectTypeHierarchy(Symbol.TypeSymbol symbol) { + Set visited = new LinkedHashSet<>(); + walkTypeHierarchy(symbol, visited); + return visited; + } + + private static void walkTypeHierarchy(Symbol.TypeSymbol symbol, Set visited) { + String fqn = symbol.type().fullyQualifiedName(); + if ("java.lang.Object".equals(fqn) || symbol.type().isUnknown() || !visited.add(fqn)) { + return; + } + Type superClass = symbol.superClass(); + if (superClass != null && !superClass.isUnknown()) { + walkTypeHierarchy(superClass.symbol(), visited); + } + for (Type iface : symbol.interfaces()) { + if (!iface.isUnknown()) { + walkTypeHierarchy(iface.symbol(), visited); + } + } + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java b/java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java index f1dc9d323be..0bcb472878e 100644 --- a/java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java +++ b/java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java @@ -16,7 +16,11 @@ */ package org.sonar.java.utils; +import java.beans.Introspector; +import java.util.Arrays; import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; import org.sonar.java.model.ExpressionUtils; import org.sonar.plugins.java.api.semantic.Symbol; @@ -52,6 +56,8 @@ public final class SpringUtils { CONFIGURATION_ANNOTATION ); + public static final String VALUE_ATTRIBUTE = "value"; + private SpringUtils() { // Utils class } @@ -63,7 +69,7 @@ public static boolean isScopeSingleton(SymbolMetadata clazzMeta) { return true; } for (SymbolMetadata.AnnotationValue annotationValue : values) { - if ("value".equals(annotationValue.name()) || "scopeName".equals(annotationValue.name())) { + if (VALUE_ATTRIBUTE.equals(annotationValue.name()) || "scopeName".equals(annotationValue.name())) { Object value = annotationValue.value(); if (value instanceof String stringValue && !"singleton".equals(stringValue)) { return false; @@ -90,6 +96,58 @@ public static boolean isSpringBootUnitTest(MethodTree methodTree) { return UnitTestUtils.isUnitTest(methodTree) && SpringUtils.isSpringBootTestClass(parentClass.symbol()); } + /** + * Resolves the Spring bean name for a stereotype-annotated class. + * Returns the explicit name from the annotation if present, otherwise the decapitalized simple class name. + */ + public static String resolveStereotypeBeanName(SymbolMetadata meta, String simpleName) { + for (String annotation : STEREOTYPE_ANNOTATIONS) { + List attrs = meta.valuesForAnnotation(annotation); + if (attrs != null) { + Optional name = attrs.stream() + .filter(v -> VALUE_ATTRIBUTE.equals(v.name())) + .map(v -> (String) v.value()) + .filter(s -> s != null && !s.isBlank()) + .findFirst(); + if (name.isPresent()) { + return name.get(); + } + } + } + return Introspector.decapitalize(simpleName); + } + + /** + * Resolves all Spring bean names for a {@code @Bean} factory method, including aliases. + * Returns the explicit names from the annotation if present, otherwise a singleton list of the method name. + */ + public static List resolveBeanMethodNames(MethodTree method) { + List attrs = method.symbol().metadata().valuesForAnnotation(BEAN_ANNOTATION); + if (attrs == null) { + return List.of(method.simpleName().name()); + } + List names = attrs.stream() + .filter(v -> VALUE_ATTRIBUTE.equals(v.name()) || "name".equals(v.name())) + .flatMap(v -> { + Object val = v.value(); + if (val instanceof Object[] arr) { + return Arrays.stream(arr).filter(String.class::isInstance).map(String.class::cast); + } + return Stream.empty(); + }) + .filter(s -> !s.isBlank()) + .toList(); + return names.isEmpty() ? List.of(method.simpleName().name()) : names; + } + + /** + * Resolves the primary Spring bean name for a {@code @Bean} factory method. + * Returns the first explicit name from the annotation if present, otherwise the method name. + */ + public static String resolveBeanMethodName(MethodTree method) { + return resolveBeanMethodNames(method).get(0); + } + public static List getBeanMethods(ClassTree classTree) { return classTree.members().stream() .filter(member -> member.is(Tree.Kind.METHOD)) diff --git a/java-frontend/src/test/files/springcontext/ComponentImplementingInterface.java b/java-frontend/src/test/files/springcontext/ComponentImplementingInterface.java new file mode 100644 index 00000000000..82670f3fb83 --- /dev/null +++ b/java-frontend/src/test/files/springcontext/ComponentImplementingInterface.java @@ -0,0 +1,14 @@ +package checks.spring.context; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +@Component +class ComponentImplementingInterface implements ApplicationContextAware { + + @Override + public void setApplicationContext(ApplicationContext ctx) { + // not needed for test + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java new file mode 100644 index 00000000000..bcf5df67043 --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java @@ -0,0 +1,142 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model.springcontext; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class TypeToBeanNamesIndexGathererTest extends SpringContextGathererTest { + + @BeforeEach + void setUp() { + gatherer = new TypeToBeanNamesIndexGatherer(); + model = new SpringContextModel(); + } + + // ---- Stereotype beans ------------------------------------------------------- + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "src/test/files/springcontext/SimpleComponent.java", + "src/test/files/springcontext/SimpleService.java", + "src/test/files/springcontext/SimpleRepository.java", + "src/test/files/springcontext/SimpleController.java", + "src/test/files/springcontext/SimpleRestController.java", + "src/test/files/springcontext/SimpleConfiguration.java" + }) + void stereotype_bean_is_registered_under_its_own_type(String filePath) { + scan(filePath); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context." + beanClassNameFrom(filePath))) + .isNotEmpty(); + } + + @Test + void bean_is_registered_under_implemented_interface() { + scan("src/test/files/springcontext/ComponentImplementingInterface.java"); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context.ComponentImplementingInterface")) + .containsOnly("componentImplementingInterface"); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContextAware")) + .containsOnly("componentImplementingInterface"); + } + + @Test + void explicit_bean_name_is_used_in_index() { + scan("src/test/files/springcontext/ExplicitNameComponent.java"); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context.ExplicitNameComponent")) + .containsOnly("myBean"); + } + + // ---- @Bean methods ---------------------------------------------------------- + + @Test + void bean_method_return_type_is_registered() { + scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContext")) + .contains("simpleServiceBean", "namedBean", "arrayNamedBean", "emptyNameArrayMethod"); + } + + @Test + void bean_method_aliases_are_all_registered() { + scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); + + // @Bean(name = {"arrayNamedBean", "alias"}) — both names must appear in the index + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContext")) + .contains("arrayNamedBean", "alias"); + } + + // ---- Multiple beans --------------------------------------------------------- + + @Test + void multiple_beans_of_same_type_all_registered() { + scan( + "src/test/files/springcontext/SimpleComponent.java", + "src/test/files/springcontext/SimpleService.java" + ); + + var index = model.getTypeToBeanNamesIndex(); + // Each bean appears only under its own concrete type + assertThat(index.getNamesForType("checks.spring.context.SimpleComponent")) + .containsOnly("simpleComponent"); + assertThat(index.getNamesForType("checks.spring.context.SimpleService")) + .containsOnly("simpleService"); + } + + // ---- No annotation ---------------------------------------------------------- + + @Test + void non_spring_class_registers_nothing() { + scan("src/test/files/springcontext/NoScanAnnotations.java"); + + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("checks.spring.context.NoScanAnnotations")) + .isEmpty(); + } + + @Test + void gatherer_skipped_when_spring_not_in_classpath() { + scan(List.of(), "src/test/files/springcontext/SimpleComponent.java"); + + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("checks.spring.context.SimpleComponent")) + .isEmpty(); + } + + @Test + void anonymous_class_is_skipped() { + scan("src/test/files/springcontext/SpringBootAppWithAnonymousClass.java"); + + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("")).isEmpty(); + } + + // ---- Helpers ---------------------------------------------------------------- + + private static String beanClassNameFrom(String filePath) { + return filePath.substring(filePath.lastIndexOf('/') + 1, filePath.lastIndexOf('.')); + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java b/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java index e326a72c7c7..aafe47812a5 100644 --- a/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java +++ b/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java @@ -16,10 +16,16 @@ */ package org.sonar.java.utils; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.sonar.java.model.JParserTestUtils; import org.sonar.java.model.declaration.ClassTreeImpl; +import org.sonar.java.model.declaration.MethodTreeImpl; import org.sonar.java.model.declaration.VariableTreeImpl; +import org.sonar.java.test.classpath.TestClasspathUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -48,4 +54,189 @@ class A { assertThat(SpringUtils.isAutowired(hoo.symbol())).isFalse(); } + // ---- isScopeSingleton ------------------------------------------------------- + + @Test + void is_scope_singleton_no_annotation_returns_true() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.stereotype.Component + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isScopeSingleton(clazz.symbol().metadata())).isTrue(); + } + + @Test + void is_scope_singleton_with_singleton_scope_returns_true() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.context.annotation.Scope("singleton") + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isScopeSingleton(clazz.symbol().metadata())).isTrue(); + } + + @Test + void is_scope_singleton_with_prototype_scope_returns_false() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.context.annotation.Scope("prototype") + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isScopeSingleton(clazz.symbol().metadata())).isFalse(); + } + + @Test + void is_scope_singleton_with_scope_name_attribute_and_prototype_returns_false() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.context.annotation.Scope(scopeName = "prototype") + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isScopeSingleton(clazz.symbol().metadata())).isFalse(); + } + + // ---- isSpringBootTestClass -------------------------------------------------- + + @Test + void is_spring_boot_test_class_with_annotation_returns_true() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.boot.test.context.SpringBootTest + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isSpringBootTestClass(clazz.symbol())).isTrue(); + } + + @Test + void is_spring_boot_test_class_without_annotation_returns_false() { + var cu = JParserTestUtils.parse("class A {}"); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isSpringBootTestClass(clazz.symbol())).isFalse(); + } + + // ---- isSpringBootUnitTest --------------------------------------------------- + + @Test + void is_spring_boot_unit_test_method_in_interface_returns_false() { + // getParentOfType(method, CLASS) returns null for methods inside interfaces (kind is INTERFACE, not CLASS) + var cu = JParserTestUtils.parse("interface A { default void m() {} }"); + var iface = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) iface.members().get(0); + assertThat(SpringUtils.isSpringBootUnitTest(method)).isFalse(); + } + + @Test + void is_spring_boot_unit_test_in_spring_boot_test_class_returns_true() { + var cu = JParserTestUtils.parse("A", """ + import org.junit.jupiter.api.Test; + @org.springframework.boot.test.context.SpringBootTest + class A { + @Test + void myTest() {} + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.isSpringBootUnitTest(method)).isTrue(); + } + + @Test + void is_spring_boot_unit_test_in_non_spring_class_returns_false() { + var cu = JParserTestUtils.parse("A", """ + import org.junit.jupiter.api.Test; + class A { + @Test + void myTest() {} + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.isSpringBootUnitTest(method)).isFalse(); + } + + // ---- resolveStereotypeBeanName ---------------------------------------------- + + @Test + void resolve_stereotype_bean_name_uses_name_attribute() { + // Covers the "name".equals(v.name()) branch in the filter + var cu = JParserTestUtils.parse("A", """ + @org.springframework.stereotype.Service(value = "myService") + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.resolveStereotypeBeanName(clazz.symbol().metadata(), "A")).isEqualTo("myService"); + } + + @Test + void resolve_stereotype_bean_name_falls_back_to_decapitalized_name() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.stereotype.Component + class MyServiceImpl {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.resolveStereotypeBeanName(clazz.symbol().metadata(), "MyServiceImpl")).isEqualTo("myServiceImpl"); + } + + // ---- resolveBeanMethodNames ------------------------------------------------- + + @ParameterizedTest(name = "{0}") + @MethodSource("fallBackToMethodNameArguments") + void resolve_bean_method_names_falls_back_to_method_name(String description, String source) { + var cu = JParserTestUtils.parse("A", source, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myMethod"); + } + + static Stream fallBackToMethodNameArguments() { + return Stream.of( + Arguments.of("no annotation", "class A { Object myMethod() { return null; } }"), + Arguments.of("empty array", "import org.springframework.context.annotation.Bean; class A { @Bean(name = {}) Object myMethod() { return null; } }"), + Arguments.of("non-name/value attribute","import org.springframework.context.annotation.Bean; class A { @Bean(initMethod = \"init\") Object myMethod() { return null; } }"), + Arguments.of("blank name", "import org.springframework.context.annotation.Bean; class A { @Bean(name = \"\") Object myMethod() { return null; } }") + ); + } + + @Test + void resolve_bean_method_names_single_string_name() { + var cu = JParserTestUtils.parse("A", """ + import org.springframework.context.annotation.Bean; + class A { + @Bean(name = "myBean") + Object myMethod() { return null; } + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myBean"); + } + + @Test + void resolve_bean_method_names_array_includes_all_aliases() { + var cu = JParserTestUtils.parse("A", """ + import org.springframework.context.annotation.Bean; + class A { + @Bean(name = {"primary", "alias"}) + Object myMethod() { return null; } + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.resolveBeanMethodNames(method)).containsExactlyInAnyOrder("primary", "alias"); + } + + @Test + void resolve_bean_method_name_delegates_to_first_name() { + var cu = JParserTestUtils.parse("A", """ + import org.springframework.context.annotation.Bean; + class A { + @Bean(name = {"first", "second"}) + Object myMethod() { return null; } + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.resolveBeanMethodName(method)).isEqualTo("first"); + } }