diff --git a/headless-services/commons/commons-rewrite/pom.xml b/headless-services/commons/commons-rewrite/pom.xml index 28cc187b96..3b87d422af 100644 --- a/headless-services/commons/commons-rewrite/pom.xml +++ b/headless-services/commons/commons-rewrite/pom.xml @@ -110,6 +110,12 @@ rewrite-spring + + org.openrewrite + rewrite-test + test + + org.springframework.ide.vscode commons-maven diff --git a/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/ORDocUtils.java b/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/ORDocUtils.java index e9deeda714..26c72959a0 100644 --- a/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/ORDocUtils.java +++ b/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/ORDocUtils.java @@ -21,6 +21,7 @@ import org.eclipse.lsp4j.DeleteFile; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.ResourceOperation; import org.eclipse.lsp4j.TextDocumentEdit; import org.eclipse.lsp4j.TextEdit; import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; @@ -72,6 +73,44 @@ public static Optional computeEdits(IDocument doc, Result result) } + public static Optional computeDocumentEdits(WorkspaceEdit we, IDocument doc) { + if (!we.getDocumentChanges().isEmpty()) { + DocumentEdits edits = new DocumentEdits(doc, false); + List> changes = we.getDocumentChanges(); + for (Either change : changes) { + if (change.isLeft()) { + TextDocumentEdit textDocumentEdit = change.getLeft(); + List textEdits = textDocumentEdit.getEdits(); + for (TextEdit textEdit : textEdits) { + Range range = textEdit.getRange(); + Position start = range.getStart(); + Position end = range.getEnd(); + String newText = textEdit.getNewText(); + + try { + int startOffset = doc.getLineOffset(start.getLine()) + start.getCharacter(); + int endOffset = doc.getLineOffset(end.getLine()) + end.getCharacter(); + + if (startOffset == endOffset) { + edits.insert(startOffset, newText); + } else if (newText.isEmpty()) { + edits.delete(startOffset, endOffset); + } else { + edits.replace(startOffset, endOffset, newText); + } + } catch (BadLocationException ex) { + log.error("Failed to apply text edit", ex); + } + } + } + } + + return Optional.of(edits); + } + return Optional.empty(); + + } + public static Optional computeTextDocEdit(TextDocument doc, String oldContent, String newContent, String changeAnnotationId) { TextDocument newDoc = new TextDocument(null, LanguageId.PLAINTEXT, 0, newContent); @@ -161,7 +200,7 @@ public static Optional createWorkspaceEdit(SimpleTextDocumentServ } WorkspaceEdit we = new WorkspaceEdit(); we.setDocumentChanges(new ArrayList<>()); - for (Result result : results) { + for (Result result : results) { if (result.getBefore() == null) { String docUri = result.getAfter().getSourcePath().toUri().toASCIIString(); createNewFileEdit(docUri, result.getAfter().printAll(), changeAnnotationId, we); diff --git a/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/AddFieldRecipe.java b/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/AddFieldRecipe.java new file mode 100644 index 0000000000..6b2ba79fcc --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/AddFieldRecipe.java @@ -0,0 +1,129 @@ +/******************************************************************************* + * Copyright (c) 2017, 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.commons.rewrite.java; + +import org.openrewrite.Cursor; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.lang.NonNull; +import org.openrewrite.internal.lang.Nullable; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.JavaParser; +import org.openrewrite.java.JavaTemplate; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.TypeUtils; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author Udayani V + */ +public class AddFieldRecipe extends Recipe { + + @Override + public String getDisplayName() { + return "Add field"; + } + + @Override + public String getDescription() { + return "Add field desccription."; + } + + @NonNull + @Nullable + String fullyQualifiedName; + + @NonNull + @Nullable + String classFqName; + + @JsonCreator + public AddFieldRecipe(@NonNull @JsonProperty("fullyQualifiedClassName") String fullyQualifiedName, @NonNull @JsonProperty("classFqName") String classFqName) { + this.fullyQualifiedName = fullyQualifiedName; + this.classFqName = classFqName; + } + + @Override + public TreeVisitor getVisitor() { + + return new JavaIsoVisitor() { + + JavaType.FullyQualified fullyQualifiedType = JavaType.ShallowClass.build(fullyQualifiedName); + String fieldType = getFieldType(fullyQualifiedType); + String fieldName = getFieldName(fullyQualifiedType); + + private final JavaTemplate fieldTemplate = JavaTemplate.builder("private final %s %s;" + .formatted(fieldType, fieldName)) + .javaParser(JavaParser.fromJavaVersion() + .dependsOn( + """ + package %s; + + public interface %s {} + """.formatted(fullyQualifiedType.getPackageName(), fullyQualifiedType.getClassName()), + """ + package %s; + + public class A { + public class %s { + + } + } + """.formatted(fullyQualifiedType.getPackageName(), fullyQualifiedType.getClassName())) + ) + .imports(fullyQualifiedType.getFullyQualifiedName()) + .contextSensitive() + .build(); + + @Override + public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext ctx) { + if (TypeUtils.isOfClassType(classDecl.getType(), classFqName)) { + + // Check if the class already has the field + boolean hasOwnerRepoField = classDecl.getBody().getStatements().stream() + .filter(J.VariableDeclarations.class::isInstance).map(J.VariableDeclarations.class::cast) + .anyMatch(varDecl -> varDecl.getTypeExpression() != null + && varDecl.getTypeExpression().toString().equals(fieldType)); + + if (!hasOwnerRepoField) { + classDecl = classDecl.withBody(fieldTemplate.apply(new Cursor(getCursor(), classDecl.getBody()), + classDecl.getBody().getCoordinates().firstStatement())); + + maybeAddImport(fullyQualifiedType.getFullyQualifiedName(), false); + } + return classDecl; + } + classDecl = (J.ClassDeclaration) super.visitClassDeclaration(classDecl, ctx); + return classDecl; + } + }; + } + + private static String getFieldName(JavaType.FullyQualified fullyQualifiedType) { + return Character.toLowerCase(fullyQualifiedType.getClassName().charAt(0)) + fullyQualifiedType.getClassName().substring(1); + } + + private static String getFieldType(JavaType.FullyQualified fullyQualifiedType) { + if(fullyQualifiedType.getOwningClass() != null) { + String[] parts = fullyQualifiedType.getFullyQualifiedName().split("\\."); + if (parts.length < 2) { + return fullyQualifiedType.getClassName(); + } + return parts[parts.length - 2] + "." + parts[parts.length - 1]; + } + + return fullyQualifiedType.getClassName(); + } +} diff --git a/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/ConstructorInjectionRecipe.java b/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/ConstructorInjectionRecipe.java new file mode 100644 index 0000000000..96430f02ae --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/ConstructorInjectionRecipe.java @@ -0,0 +1,292 @@ +/******************************************************************************* + * Copyright (c) 2017, 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.commons.rewrite.java; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.openrewrite.Cursor; +import org.openrewrite.ExecutionContext; +import org.openrewrite.NlsRewrite.Description; +import org.openrewrite.NlsRewrite.DisplayName; +import org.openrewrite.Recipe; +import org.openrewrite.Tree; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.internal.lang.NonNull; +import org.openrewrite.internal.lang.Nullable; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.JavaTemplate; +import org.openrewrite.java.JavaVisitor; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.J.Block; +import org.openrewrite.java.tree.J.ClassDeclaration; +import org.openrewrite.java.tree.J.MethodDeclaration; +import org.openrewrite.java.tree.J.VariableDeclarations; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.JavaType.FullyQualified; +import org.openrewrite.java.tree.Space; +import org.openrewrite.java.tree.Statement; +import org.openrewrite.java.tree.TypeTree; +import org.openrewrite.java.tree.TypeUtils; +import org.openrewrite.marker.Markers; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author Udayani V + */ +public class ConstructorInjectionRecipe extends Recipe { + + @Override + public @DisplayName String getDisplayName() { + return "Add bean injection"; + } + + @Override + public @Description String getDescription() { + return "Add bean injection."; + } + + @NonNull + @Nullable + String fullyQualifiedName; + + @NonNull + @Nullable + String fieldName; + + @NonNull + @Nullable + String classFqName; + + @JsonCreator + public ConstructorInjectionRecipe(@NonNull @JsonProperty("fullyQualifiedClassName") String fullyQualifiedName, + @NonNull @JsonProperty("fieldName") String fieldName, + @NonNull @JsonProperty("classFqName") String classFqName) { + this.fullyQualifiedName = fullyQualifiedName; + this.fieldName = fieldName; + this.classFqName = classFqName; + } + + @Override + public TreeVisitor getVisitor() { + + return new CustomFieldIntoConstructorParameterVisitor(classFqName, fieldName); + } + + class CustomFieldIntoConstructorParameterVisitor extends JavaVisitor { + + private final String classFqName; + private final String fieldName; + private static final String AUTOWIRED = "org.springframework.beans.factory.annotation.Autowired"; + + public CustomFieldIntoConstructorParameterVisitor(String classFqName, String fieldName) { + this.classFqName = classFqName; + this.fieldName = fieldName; + } + + @Override + public J visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext ctx) { + + if (TypeUtils.isOfClassType(classDecl.getType(), classFqName)) { + List constructors = classDecl.getBody().getStatements().stream() + .filter(J.MethodDeclaration.class::isInstance).map(J.MethodDeclaration.class::cast) + .filter(MethodDeclaration::isConstructor).collect(Collectors.toList()); + boolean applicable = false; + if (constructors.isEmpty()) { + applicable = true; + } else if (constructors.size() == 1) { + MethodDeclaration c = constructors.get(0); + getCursor().putMessage("applicableConstructor", c); + applicable = isNotConstructorInitializingField(c, fieldName); + } else { + List autowiredConstructors = constructors.stream() + .filter(constr -> constr.getLeadingAnnotations().stream() + .map(a -> TypeUtils.asFullyQualified(a.getType())).filter(Objects::nonNull) + .map(FullyQualified::getFullyQualifiedName).anyMatch(AUTOWIRED::equals)) + .limit(2).collect(Collectors.toList()); + if (autowiredConstructors.size() == 1) { + MethodDeclaration c = autowiredConstructors.get(0); + getCursor().putMessage("applicableConstructor", autowiredConstructors.get(0)); + applicable = isNotConstructorInitializingField(c, fieldName); + } + } + if (applicable) { + return super.visitClassDeclaration(classDecl, ctx); + } + } + return super.visitClassDeclaration(classDecl, ctx); + } + + public static boolean isNotConstructorInitializingField(MethodDeclaration c, String fieldName) { + return c.getBody() == null || c.getBody().getStatements().stream().filter(J.Assignment.class::isInstance) + .map(J.Assignment.class::cast).noneMatch(a -> { + Expression expr = a.getVariable(); + if (expr instanceof J.FieldAccess) { + J.FieldAccess fa = (J.FieldAccess) expr; + if (fieldName.equals(fa.getSimpleName()) && fa.getTarget() instanceof J.Identifier) { + J.Identifier target = (J.Identifier) fa.getTarget(); + if ("this".equals(target.getSimpleName())) { + return true; + } + } + } + if (expr instanceof J.Identifier) { + JavaType.Variable fieldType = c.getMethodType().getDeclaringType().getMembers().stream() + .filter(v -> fieldName.equals(v.getName())).findFirst().orElse(null); + if (fieldType != null) { + J.Identifier identifier = (J.Identifier) expr; + return fieldType.equals(identifier.getFieldType()); + } + } + return false; + }); + } + + @Override + public J.VariableDeclarations visitVariableDeclarations(J.VariableDeclarations multiVariable, + ExecutionContext ctx) { + + Cursor blockCursor = getCursor().dropParentUntil(it -> it instanceof J.Block || it == Cursor.ROOT_VALUE); + if (!(blockCursor.getValue() instanceof J.Block)) { + return multiVariable; + } + VariableDeclarations mv = multiVariable; + if (blockCursor.getParent() != null && blockCursor.getParent().getValue() instanceof ClassDeclaration + && multiVariable.getVariables().size() == 1 + && fieldName.equals(multiVariable.getVariables().get(0).getName().getSimpleName())) { + if (mv.getModifiers().stream().noneMatch(m -> m.getType() == J.Modifier.Type.Final)) { + Space prefix = Space.firstPrefix(mv.getVariables()); + J.Modifier m = new J.Modifier(Tree.randomId(), Space.EMPTY, Markers.EMPTY, null, + J.Modifier.Type.Final, Collections.emptyList()); + if (mv.getModifiers().isEmpty()) { + mv = mv.withTypeExpression(mv.getTypeExpression().withPrefix(prefix)); + } else { + m = m.withPrefix(prefix); + } + mv = mv.withModifiers(ListUtils.concat(mv.getModifiers(), m)); + } + MethodDeclaration constructor = blockCursor.getParent().getMessage("applicableConstructor"); + ClassDeclaration c = blockCursor.getParent().getValue(); + TypeTree fieldType = TypeTree.build(fullyQualifiedName); + if (constructor == null) { + doAfterVisit(new AddConstructorVisitor(c.getSimpleName(), fieldName, fieldType)); + } else { + doAfterVisit(new AddConstructorParameterAndAssignment(constructor, fieldName, fieldType)); + } + } + return mv; + } + } + + private static class AddConstructorVisitor extends JavaVisitor { + private final String className; + private final String fieldName; + private final TypeTree type; + + public AddConstructorVisitor(String className, String fieldName, TypeTree type) { + this.className = className; + this.fieldName = fieldName; + this.type = type; + } + + @Override + public J visitBlock(Block block, ExecutionContext p) { + J result = (Block) super.visitBlock(block, p); + if (getCursor().getParent() != null) { + Object n = getCursor().getParent().getValue(); + if (n instanceof ClassDeclaration) { + ClassDeclaration classDecl = (ClassDeclaration) n; + JavaType.FullyQualified typeFqn = TypeUtils.asFullyQualified(type.getType()); + if (typeFqn != null && classDecl.getKind() == ClassDeclaration.Kind.Type.Class + && className.equals(classDecl.getSimpleName())) { + JavaTemplate.Builder template = JavaTemplate.builder("" + + classDecl.getSimpleName() + "(" + getFieldType(typeFqn) + " " + fieldName + ") {\n" + + "this." + fieldName + " = " + fieldName + ";\n" + + "}\n" + ).contextSensitive(); + FullyQualified fq = TypeUtils.asFullyQualified(type.getType()); + if (fq != null) { + template.imports(fq.getFullyQualifiedName()); + maybeAddImport(fq); + } + Optional firstMethod = block.getStatements().stream() + .filter(MethodDeclaration.class::isInstance).findFirst(); + + return firstMethod + .map(statement -> (J) template.build().apply(getCursor(), + statement.getCoordinates().before())) + .orElseGet(() -> template.build().apply(getCursor(), + block.getCoordinates().lastStatement())); + } + } + } + return result; + } + } + + private static class AddConstructorParameterAndAssignment extends JavaIsoVisitor { + private final MethodDeclaration constructor; + private final String fieldName; + private final String methodType; + + public AddConstructorParameterAndAssignment(MethodDeclaration constructor, String fieldName, TypeTree type) { + this.constructor = constructor; + this.fieldName = fieldName; + JavaType.FullyQualified fq = TypeUtils.asFullyQualified(type.getType()); + if (fq != null) { + methodType = getFieldType(fq); + } else { + throw new IllegalArgumentException("Unable to determine parameter type"); + } + } + + @Override + public MethodDeclaration visitMethodDeclaration(MethodDeclaration method, ExecutionContext p) { + J.MethodDeclaration md = super.visitMethodDeclaration(method, p); + if (md == this.constructor && md.getBody() != null) { + List params = md.getParameters().stream().filter(s -> !(s instanceof J.Empty)) + .collect(Collectors.toList()); + String paramsStr = Stream + .concat(params.stream().map(s -> "#{}"), Stream.of(methodType + " " + fieldName)) + .collect(Collectors.joining(", ")); + + md = JavaTemplate.builder(paramsStr).contextSensitive().build().apply(getCursor(), + md.getCoordinates().replaceParameters(), params.toArray()); + updateCursor(md); + + // noinspection ConstantConditions + md = JavaTemplate.builder("this." + fieldName + " = " + fieldName + ";").contextSensitive().build() + .apply(getCursor(), md.getBody().getCoordinates().lastStatement()); + } + return md; + } + } + + private static String getFieldType(JavaType.FullyQualified fullyQualifiedType) { + if (fullyQualifiedType.getOwningClass() != null) { + String[] parts = fullyQualifiedType.getFullyQualifiedName().split("\\."); + if (parts.length < 2) { + return fullyQualifiedType.getClassName(); + } + return parts[parts.length - 2] + "." + parts[parts.length - 1]; + } + + return fullyQualifiedType.getClassName(); + } +} \ No newline at end of file diff --git a/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/InjectBeanCompletionRecipe.java b/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/InjectBeanCompletionRecipe.java new file mode 100644 index 0000000000..59d29aab91 --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/InjectBeanCompletionRecipe.java @@ -0,0 +1,63 @@ +/******************************************************************************* + * Copyright (c) 2017, 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.commons.rewrite.java; + +import java.util.ArrayList; +import java.util.List; + +import org.openrewrite.NlsRewrite.Description; +import org.openrewrite.NlsRewrite.DisplayName; +import org.openrewrite.Recipe; +import org.openrewrite.internal.lang.NonNull; +import org.openrewrite.internal.lang.Nullable; + +/** + * @author Udayani V + */ +public class InjectBeanCompletionRecipe extends Recipe { + + @Override + public @DisplayName String getDisplayName() { + return "Inject bean completions"; + } + + @Override + public @Description String getDescription() { + return "Automates the injection of a specified bean into Spring components by adding the necessary field and import, creating the constructor if it doesn't exist, and injecting the bean as a constructor parameter."; + } + + @NonNull + @Nullable + String fullyQualifiedName; + + @NonNull + @Nullable + String fieldName; + + @NonNull + @Nullable + String classFqName; + + public InjectBeanCompletionRecipe(String fullyQualifiedName, String fieldName, String classFqName) { + this.fullyQualifiedName = fullyQualifiedName; + this.fieldName = fieldName; + this.classFqName = classFqName; + } + + @Override + public List getRecipeList() { + List list = new ArrayList<>(); + list.add(new AddFieldRecipe(fullyQualifiedName, classFqName)); + list.add(new ConstructorInjectionRecipe(fullyQualifiedName, fieldName, classFqName)); + return list; + } + +} diff --git a/headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/AddFieldRecipeTest.java b/headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/AddFieldRecipeTest.java new file mode 100644 index 0000000000..534107b19b --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/AddFieldRecipeTest.java @@ -0,0 +1,393 @@ +/******************************************************************************* + * Copyright (c) 2017, 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.commons.rewrite.java; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.openrewrite.InMemoryExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.RecipeRun; +import org.openrewrite.SourceFile; +import org.openrewrite.internal.InMemoryLargeSourceSet; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; +import org.openrewrite.tree.ParseError; + +public class AddFieldRecipeTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new AddFieldRecipe("com.example.test.OwnerRepository", "com.example.demo.FooBar")) + .parser(JavaParser.fromJavaVersion() + .logCompilationWarningsAndErrors(true)); + } + + public static void runRecipeAndAssert(Recipe recipe, String beforeSourceStr, String sourceStrPassed, String expectedSourceStr, String dependsOn) { + JavaParser javaParser = JavaParser.fromJavaVersion().dependsOn(dependsOn).build(); + + List list = javaParser.parse(beforeSourceStr).map(sf -> { + if (sf instanceof ParseError pe) { + return pe.getErroneous(); + } + return sf; + }).toList(); + SourceFile beforeSource = list.get(0); + + assertThat(beforeSource.printAll()).isEqualTo(sourceStrPassed); + + InMemoryLargeSourceSet ss = new InMemoryLargeSourceSet(list); + RecipeRun recipeRun = recipe.run(ss, new InMemoryExecutionContext(t -> { + throw new RuntimeException(t); + })); + org.openrewrite.Result res = recipeRun.getChangeset().getAllResults().get(0); + assertThat(res.getAfter().printAll()).isEqualTo(expectedSourceStr); + } + + // The test parses invalid LST and then applies the recipe + @Test + void addField() { + + String beforeSourceStr = """ + package com.example.demo; + + class FooBar { + + public void test() { + ownerR + + } + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + class FooBar { + + public void test() {} + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + class FooBar { + + private final OwnerRepository ownerRepository; + + public void test() {} + + } + """; + + String dependsOn = """ + package com.example.demo; + public interface OwnerRepository{} + """; + + Recipe recipe = new AddFieldRecipe("com.example.demo.OwnerRepository", "com.example.demo.FooBar"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void addFieldAndImport() { + + String beforeSourceStr = """ + package com.example.demo; + + class FooBar { + + public void test() { + ownerR + + } + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + class FooBar { + + public void test() {} + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + class FooBar { + + private final OwnerRepository ownerRepository; + + public void test() {} + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new AddFieldRecipe("com.example.test.OwnerRepository", "com.example.demo.FooBar"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void addNestedField() { + + String beforeSourceStr = """ + package com.example.demo; + + class FooBar { + + public void test() { + ownerR + + } + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + class FooBar { + + public void test() {} + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.Inner.OwnerRepository; + + class FooBar { + + private final Inner.OwnerRepository ownerRepository; + + public void test() {} + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new AddFieldRecipe("com.example.test.Inner.OwnerRepository", "com.example.demo.FooBar"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void addToNestedComponent() { + + String beforeSourceStr = """ + package com.example.demo; + + class FooBar { + class Inner { + public void test() { + ownerR + } + } + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + class FooBar { + class Inner { + public void test() {} + } + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + +import com.example.test.OwnerRepository; + +class FooBar { + class Inner { + private final OwnerRepository ownerRepository; + public void test() {} + } + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new AddFieldRecipe("com.example.test.OwnerRepository", "com.example.demo.FooBar$Inner"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void addFieldToFirstClass() { + + String beforeSourceStr = """ + package com.example.demo; + + class FooBar { + + public void test() { + ownerR + + } + + } + class FooBarNew { + + public void test1() {} + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + class FooBar { + + public void test() {} + + } + class FooBarNew { + + public void test1() {} + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.Inner.OwnerRepository; + + class FooBar { + + private final Inner.OwnerRepository ownerRepository; + + public void test() {} + + } + class FooBarNew { + + public void test1() {} + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new AddFieldRecipe("com.example.test.Inner.OwnerRepository", "com.example.demo.FooBar"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void addFieldToSecondClass() { + + String beforeSourceStr = """ + package com.example.demo; + + import org.springframework.stereotype.Component; + + @Component + class FooBar { + + public void test() { + ownerR + + } + + } + @Component + class FooBarNew { + + public void test1() {} + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + import org.springframework.stereotype.Component; + + @Component + class FooBar { + + public void test() {} + + } + @Component + class FooBarNew { + + public void test1() {} + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.Inner.OwnerRepository; +import org.springframework.stereotype.Component; + +@Component + class FooBar { + + public void test() {} + + } + @Component + class FooBarNew { + + private final Inner.OwnerRepository ownerRepository; + + public void test1() {} + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new AddFieldRecipe("com.example.test.Inner.OwnerRepository", "com.example.demo.FooBarNew"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + +} diff --git a/headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/ConstructorInjectionRecipeTest.java b/headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/ConstructorInjectionRecipeTest.java new file mode 100644 index 0000000000..3f765cc106 --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/ConstructorInjectionRecipeTest.java @@ -0,0 +1,649 @@ +/******************************************************************************* + * Copyright (c) 2017, 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.commons.rewrite.java; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.openrewrite.InMemoryExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.RecipeRun; +import org.openrewrite.SourceFile; +import org.openrewrite.internal.InMemoryLargeSourceSet; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; +import org.openrewrite.tree.ParseError; + +public class ConstructorInjectionRecipeTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new ConstructorInjectionRecipe("com.example.test.OwnerRepository", "ownerRepository", "com.example.demo.A")) + .parser(JavaParser.fromJavaVersion().classpath("spring-beans")); + } + + public static void runRecipeAndAssert(Recipe recipe, String beforeSourceStr, String sourceStrPassed, String expectedSourceStr, String dependsOn) { + JavaParser javaParser = JavaParser.fromJavaVersion().dependsOn(dependsOn).build(); + + List list = javaParser.parse(beforeSourceStr).map(sf -> { + if (sf instanceof ParseError pe) { + return pe.getErroneous(); + } + return sf; + }).toList(); + SourceFile beforeSource = list.get(0); + + assertThat(beforeSource.printAll()).isEqualTo(sourceStrPassed); + + InMemoryLargeSourceSet ss = new InMemoryLargeSourceSet(list); + RecipeRun recipeRun = recipe.run(ss, new InMemoryExecutionContext(t -> { + throw new RuntimeException(t); + })); + org.openrewrite.Result res = recipeRun.getChangeset().getAllResults().get(0); + assertThat(res.getAfter().printAll()).isEqualTo(expectedSourceStr); + } + + @Test + void injectFieldIntoNewConstructor() { + + String beforeSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + + private final OwnerRepository ownerRepository; + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + + private final OwnerRepository ownerRepository; + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + + private final OwnerRepository ownerRepository; + + A(OwnerRepository ownerRepository) { + this.ownerRepository = ownerRepository; + } + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new ConstructorInjectionRecipe("com.example.test.OwnerRepository", "ownerRepository", "com.example.demo.A"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void injectFieldIntoExistingSingleConstructor() { + + String beforeSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + + private final OwnerRepository ownerRepository; + + A() { + } + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + + private final OwnerRepository ownerRepository; + + A() { + } + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + + private final OwnerRepository ownerRepository; + + A(OwnerRepository ownerRepository) { + this.ownerRepository = ownerRepository; + } + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new ConstructorInjectionRecipe("com.example.test.OwnerRepository", "ownerRepository", "com.example.demo.A"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void injectFieldIntoAutowiredConstructor() { + + String beforeSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + import org.springframework.beans.factory.annotation.Autowired; + + public class A { + + private final OwnerRepository ownerRepository; + + @Autowired + A() { + } + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + import org.springframework.beans.factory.annotation.Autowired; + + public class A { + + private final OwnerRepository ownerRepository; + + @Autowired + A() { + } + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + import org.springframework.beans.factory.annotation.Autowired; + + public class A { + + private final OwnerRepository ownerRepository; + + @Autowired + A(OwnerRepository ownerRepository) { + this.ownerRepository = ownerRepository; + } + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new ConstructorInjectionRecipe("com.example.test.OwnerRepository", "ownerRepository", "com.example.demo.A"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void injectFieldIntoExistingConstructorWithFields() { + + String beforeSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + + String a; + + private final OwnerRepository ownerRepository; + + A(String a) { + this.a = a; + } + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + + String a; + + private final OwnerRepository ownerRepository; + + A(String a) { + this.a = a; + } + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + + String a; + + private final OwnerRepository ownerRepository; + + A(String a, OwnerRepository ownerRepository) { +this.a = a; + this.ownerRepository = ownerRepository; + } + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new ConstructorInjectionRecipe("com.example.test.OwnerRepository", "ownerRepository", "com.example.demo.A"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void injectInnerClassFieldIntoExistingConstructorWithFields() { + + String beforeSourceStr = """ + package com.example.demo; + + import com.example.test.Inner.OwnerRepository; + + public class A { + + String a; + + private final Inner.OwnerRepository ownerRepository; + + A(String a) { + this.a = a; + } + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + import com.example.test.Inner.OwnerRepository; + + public class A { + + String a; + + private final Inner.OwnerRepository ownerRepository; + + A(String a) { + this.a = a; + } + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.Inner.OwnerRepository; + + public class A { + + String a; + + private final Inner.OwnerRepository ownerRepository; + + A(String a, Inner.OwnerRepository ownerRepository) { +this.a = a; + this.ownerRepository = ownerRepository; + } + } + """; + + String dependsOn = """ + package com.example.test; + public class Inner { + public static class OwnerRepository{} + } + """; + + Recipe recipe = new ConstructorInjectionRecipe("com.example.test.Inner.OwnerRepository", "ownerRepository", "com.example.demo.A"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void injectInnerClassFieldIntoNewConstructor() { + + String beforeSourceStr = """ + package com.example.demo; + + import com.example.test.Inner.OwnerRepository; + + public class A { + + private final Inner.OwnerRepository ownerRepository; + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + import com.example.test.Inner.OwnerRepository; + + public class A { + + private final Inner.OwnerRepository ownerRepository; + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.Inner.OwnerRepository; + + public class A { + + private final Inner.OwnerRepository ownerRepository; + + A(Inner.OwnerRepository ownerRepository) { + this.ownerRepository = ownerRepository; + } + + } + """; + + String dependsOn = """ + package com.example.test; + public class Inner { + public static class OwnerRepository{} + } + """; + + Recipe recipe = new ConstructorInjectionRecipe("com.example.test.Inner.OwnerRepository", "ownerRepository", "com.example.demo.A"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void nestedClass_InjectFieldIntoNewConstructor() { + + String beforeSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + public class Inner { + String a; + private final OwnerRepository ownerRepository; + + public void test() { + + } + } + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + public class Inner { + String a; + private final OwnerRepository ownerRepository; + + public void test() { + + } + } + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { +public class Inner { + String a; + private final OwnerRepository ownerRepository; + + Inner(OwnerRepository ownerRepository) { + this.ownerRepository = ownerRepository; + } + + public void test() { + + } + } + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new ConstructorInjectionRecipe("com.example.test.OwnerRepository", "ownerRepository", "com.example.demo.A$Inner"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void nestedClass_InjectFieldIntoExistingConstructorWithFields() { + + String beforeSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + public class Inner { + String a; + private final OwnerRepository ownerRepository; + + Inner(String a) { + this.a = a; + } + + public void test() { + + } + } + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + public class Inner { + String a; + private final OwnerRepository ownerRepository; + + Inner(String a) { + this.a = a; + } + + public void test() { + + } + } + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { +public class Inner { + String a; + private final OwnerRepository ownerRepository; + + Inner(String a, OwnerRepository ownerRepository) { + this.a = a; + this.ownerRepository = ownerRepository; + } + + public void test() { + + } + } + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new ConstructorInjectionRecipe("com.example.test.OwnerRepository", "ownerRepository", "com.example.demo.A$Inner"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + @Test + void nestedClass_InjectFieldIntoExistingSingleConstructor() { + + String beforeSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + int param; + A(int param) { + this.param = param; + } + public class Inner { + private final OwnerRepository ownerRepository; + + Inner() { + } + + public void test() { + + } + } + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { + int param; + A(int param) { + this.param = param; + } + public class Inner { + private final OwnerRepository ownerRepository; + + Inner() { + } + + public void test() { + + } + } + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + public class A { +int param; +A(int param) { + this.param = param; +} +public class Inner { + private final OwnerRepository ownerRepository; + + Inner(OwnerRepository ownerRepository) { + this.ownerRepository = ownerRepository; + } + + public void test() { + + } + } + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new ConstructorInjectionRecipe("com.example.test.OwnerRepository", "ownerRepository", "com.example.demo.A$Inner"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + +} diff --git a/headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/InjectBeanCompletionRecipeTest.java b/headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/InjectBeanCompletionRecipeTest.java new file mode 100644 index 0000000000..10d18935e5 --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/InjectBeanCompletionRecipeTest.java @@ -0,0 +1,119 @@ +/******************************************************************************* + * Copyright (c) 2017, 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.commons.rewrite.java; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.openrewrite.InMemoryExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.RecipeRun; +import org.openrewrite.SourceFile; +import org.openrewrite.internal.InMemoryLargeSourceSet; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; +import org.openrewrite.tree.ParseError; + +public class InjectBeanCompletionRecipeTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new ConstructorInjectionRecipe("com.example.test.OwnerRepository", "ownerRepository", "com.example.demo.A")) + .parser(JavaParser.fromJavaVersion().classpath("spring-beans")); + } + + public static void runRecipeAndAssert(Recipe recipe, String beforeSourceStr, String sourceStrPassed, String expectedSourceStr, String dependsOn) { + JavaParser javaParser = JavaParser.fromJavaVersion().dependsOn(dependsOn).build(); + + List list = javaParser.parse(beforeSourceStr).map(sf -> { + if (sf instanceof ParseError pe) { + return pe.getErroneous(); + } + return sf; + }).toList(); + SourceFile beforeSource = list.get(0); + + assertThat(beforeSource.printAll()).isEqualTo(sourceStrPassed); + + InMemoryLargeSourceSet ss = new InMemoryLargeSourceSet(list); + RecipeRun recipeRun = recipe.run(ss, new InMemoryExecutionContext(t -> { + throw new RuntimeException(t); + })); + org.openrewrite.Result res = recipeRun.getChangeset().getAllResults().get(0); + assertThat(res.getAfter().printAll()).isEqualTo(expectedSourceStr); + } + + @Test + void injectFieldIntoNewConstructor() { + + String beforeSourceStr = """ + package com.example.demo; + + import org.springframework.stereotype.Controller; + + @Controller + public class A { + + public void test() { + } + + } + """; + + String sourceStrPassed = """ + package com.example.demo; + + import org.springframework.stereotype.Controller; + + @Controller + public class A { + + public void test() { + } + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; +import org.springframework.stereotype.Controller; + +@Controller + public class A { + + private final OwnerRepository ownerRepository; + + A(OwnerRepository ownerRepository) { + this.ownerRepository = ownerRepository; + } + +public void test() { +} + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new InjectBeanCompletionRecipe("com.example.test.OwnerRepository", "ownerRepository", "com.example.demo.A"); + runRecipeAndAssert(recipe, beforeSourceStr, sourceStrPassed, expectedSourceStr, dependsOn); + } + + +} diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootJavaCompletionEngineConfigurer.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootJavaCompletionEngineConfigurer.java index 50b739412a..2855ef2d3a 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootJavaCompletionEngineConfigurer.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootJavaCompletionEngineConfigurer.java @@ -25,6 +25,7 @@ import org.springframework.ide.vscode.boot.java.Annotations; import org.springframework.ide.vscode.boot.java.annotations.AnnotationAttributeCompletionProcessor; import org.springframework.ide.vscode.boot.java.annotations.AnnotationHierarchies; +import org.springframework.ide.vscode.boot.java.beans.BeanCompletionProvider; import org.springframework.ide.vscode.boot.java.beans.DependsOnCompletionProcessor; import org.springframework.ide.vscode.boot.java.beans.NamedCompletionProvider; import org.springframework.ide.vscode.boot.java.beans.ProfileCompletionProvider; @@ -36,6 +37,7 @@ import org.springframework.ide.vscode.boot.java.data.DataRepositoryCompletionProcessor; import org.springframework.ide.vscode.boot.java.handlers.BootJavaCompletionEngine; import org.springframework.ide.vscode.boot.java.handlers.CompletionProvider; +import org.springframework.ide.vscode.boot.java.rewrite.RewriteRefactorings; import org.springframework.ide.vscode.boot.java.scope.ScopeCompletionProcessor; import org.springframework.ide.vscode.boot.java.snippets.JavaSnippet; import org.springframework.ide.vscode.boot.java.snippets.JavaSnippetContext; @@ -111,7 +113,8 @@ BootJavaCompletionEngine javaCompletionEngine( @Qualifier("adHocProperties") ProjectBasedPropertyIndexProvider adHocProperties, JavaSnippetManager snippetManager, CompilationUnitCache cuCache, - SpringMetamodelIndex springIndex) { + SpringMetamodelIndex springIndex, + RewriteRefactorings rewriteRefactorings ) { SpringPropertyIndexProvider indexProvider = params.indexProvider; JavaProjectFinder javaProjectFinder = params.projectFinder; @@ -126,7 +129,8 @@ BootJavaCompletionEngine javaCompletionEngine( providers.put(Annotations.SCOPE, new AnnotationAttributeCompletionProcessor(javaProjectFinder, Map.of("value", new ScopeCompletionProcessor()))); providers.put(Annotations.DEPENDS_ON, new AnnotationAttributeCompletionProcessor(javaProjectFinder, Map.of("value", new DependsOnCompletionProcessor(springIndex)))); providers.put(Annotations.QUALIFIER, new AnnotationAttributeCompletionProcessor(javaProjectFinder, Map.of("value", new QualifierCompletionProvider(springIndex)))); - providers.put(Annotations.PROFILE, new AnnotationAttributeCompletionProcessor(javaProjectFinder, Map.of("value", new ProfileCompletionProvider(springIndex)))); + providers.put(Annotations.BEAN, new BeanCompletionProvider(javaProjectFinder, springIndex, rewriteRefactorings)); + providers.put(Annotations.RESOURCE_JAVAX, new AnnotationAttributeCompletionProcessor(javaProjectFinder, Map.of("name", new ResourceCompletionProvider(springIndex)))); providers.put(Annotations.RESOURCE_JAKARTA, new AnnotationAttributeCompletionProcessor(javaProjectFinder, Map.of("name", new ResourceCompletionProvider(springIndex)))); diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeanCompletionProposal.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeanCompletionProposal.java new file mode 100644 index 0000000000..5e48e7fbd6 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeanCompletionProposal.java @@ -0,0 +1,124 @@ +/******************************************************************************* + * Copyright (c) 2017, 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.java.beans; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.WorkspaceEdit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ide.vscode.boot.java.rewrite.RewriteRefactorings; +import org.springframework.ide.vscode.commons.languageserver.completion.DocumentEdits; +import org.springframework.ide.vscode.commons.languageserver.completion.ICompletionProposal; +import org.springframework.ide.vscode.commons.rewrite.ORDocUtils; +import org.springframework.ide.vscode.commons.rewrite.config.RecipeScope; +import org.springframework.ide.vscode.commons.rewrite.java.FixDescriptor; +import org.springframework.ide.vscode.commons.rewrite.java.InjectBeanCompletionRecipe; +import org.springframework.ide.vscode.commons.util.Renderable; +import org.springframework.ide.vscode.commons.util.text.IDocument; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; + +/** + * @author Udayani V + */ +public class BeanCompletionProposal implements ICompletionProposal { + + private static final Logger log = LoggerFactory.getLogger(BeanCompletionProposal.class); + + private DocumentEdits edits; + private IDocument doc; + private String label; + private String detail; + private String fieldType; + private String className; + private Renderable documentation; + private RewriteRefactorings rewriteRefactorings; + + private Gson gson; + + public BeanCompletionProposal(DocumentEdits edits, IDocument doc, String label, String detail, String fieldType, String className, + Renderable documentation, RewriteRefactorings rewriteRefactorings) { + this.edits = edits; + this.doc = doc; + this.label = label; + this.detail = detail; + this.fieldType = fieldType; + this.className = className; + this.documentation = documentation; + this.rewriteRefactorings = rewriteRefactorings; + this.gson = new GsonBuilder() + .registerTypeAdapter(RecipeScope.class, (JsonDeserializer) (json, type, context) -> { + try { + return RecipeScope.values()[json.getAsInt()]; + } catch (Exception e) { + return null; + } + }) + .create(); + } + + @Override + public String getLabel() { + return this.label; + } + + @Override + public CompletionItemKind getKind() { + return CompletionItemKind.Constructor; + } + + @Override + public DocumentEdits getTextEdit() { + return this.edits; + } + + @Override + public String getDetail() { + return this.detail; + } + + @Override + public Renderable getDocumentation() { + return this.documentation; + } + + @Override + public Optional> getAdditionalEdit() { + return Optional.of(() -> { + try { + FixDescriptor f = new FixDescriptor(InjectBeanCompletionRecipe.class.getName(), List.of(this.doc.getUri()),"Inject bean completions") + .withParameters(Map.of("fullyQualifiedName", this.fieldType, "fieldName", this.label, "classFqName",this.className)) + .withRecipeScope(RecipeScope.NODE); + JsonElement jsonElement = gson.toJsonTree(f); + CompletableFuture workspaceEdits = this.rewriteRefactorings.createEdit(jsonElement); + + CompletableFuture> docEditsFuture = workspaceEdits.thenApply(workspaceEdit -> { + Optional docEdits = ORDocUtils.computeDocumentEdits(workspaceEdit, doc); + return docEdits; + + }); + return docEditsFuture.get().orElse(null); + } catch (InterruptedException | ExecutionException e) { + log.error("" + e); + return null; + } + }); + } +} diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeanCompletionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeanCompletionProvider.java new file mode 100644 index 0000000000..49634f5f10 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeanCompletionProvider.java @@ -0,0 +1,129 @@ +/******************************************************************************* + * Copyright (c) 2017, 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.java.beans; + +import java.util.Collection; +import java.util.Optional; + +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.IAnnotationBinding; +import org.eclipse.jdt.core.dom.SimpleName; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ide.vscode.boot.index.SpringMetamodelIndex; +import org.springframework.ide.vscode.boot.java.handlers.CompletionProvider; +import org.springframework.ide.vscode.boot.java.rewrite.RewriteRefactorings; +import org.springframework.ide.vscode.commons.java.IJavaProject; +import org.springframework.ide.vscode.commons.languageserver.completion.DocumentEdits; +import org.springframework.ide.vscode.commons.languageserver.completion.ICompletionProposal; +import org.springframework.ide.vscode.commons.languageserver.java.JavaProjectFinder; +import org.springframework.ide.vscode.commons.protocol.spring.Bean; +import org.springframework.ide.vscode.commons.util.FuzzyMatcher; +import org.springframework.ide.vscode.commons.util.text.TextDocument; + +/** + * @author Udayani V + */ +public class BeanCompletionProvider implements CompletionProvider { + + private static final Logger log = LoggerFactory.getLogger(BeanCompletionProvider.class); + + private final JavaProjectFinder javaProjectFinder; + private final SpringMetamodelIndex springIndex; + private final RewriteRefactorings rewriteRefactorings; + + public BeanCompletionProvider(JavaProjectFinder javaProjectFinder, SpringMetamodelIndex springIndex, + RewriteRefactorings rewriteRefactorings) { + this.javaProjectFinder = javaProjectFinder; + this.springIndex = springIndex; + this.rewriteRefactorings = rewriteRefactorings; + } + + @Override + public void provideCompletions(ASTNode node, int offset, TextDocument doc, + Collection completions) { + try { + Optional optionalProject = this.javaProjectFinder.find(doc.getId()); + if (optionalProject.isEmpty()) { + return; + } + + IJavaProject project = optionalProject.get(); + TypeDeclaration topLevelClass = findParentClass(node); + if (topLevelClass == null) { + return; + } + + if (node instanceof SimpleName && isSpringComponent(topLevelClass)) { + String className = getFullyQualifiedName(topLevelClass); + Bean[] beans = this.springIndex.getBeansOfProject(project.getElementName()); + for (Bean bean : beans) { + if (FuzzyMatcher.matchScore(node.toString(), bean.getName()) != 0.0) { + DocumentEdits edits = new DocumentEdits(doc, false); + edits.replace(offset - node.toString().length(), offset, bean.getName()); + BeanCompletionProposal proposal = new BeanCompletionProposal(edits, doc, bean.getName(), + "Autowire bean", bean.getType(), className, null, rewriteRefactorings); + completions.add(proposal); + } + } + } + } catch (Exception e) { + log.error("problem while looking for bean completions", e); + } + } + + private static boolean isSpringComponent(TypeDeclaration node) { + for (IAnnotationBinding annotation : node.resolveBinding().getAnnotations()) { + if (isSpringComponentAnnotation(annotation)) { + return true; + } + } + return false; + } + + private static boolean isSpringComponentAnnotation(IAnnotationBinding annotation) { + String annotationName = annotation.getAnnotationType().getQualifiedName(); + if (annotationName.equals("org.springframework.stereotype.Component")) { + return true; + } + for (IAnnotationBinding metaAnnotation : annotation.getAnnotationType().getAnnotations()) { + if (metaAnnotation.getAnnotationType().getQualifiedName().equals("org.springframework.stereotype.Component")) { + return true; + } + } + return false; + } + + private static TypeDeclaration findParentClass(ASTNode node) { + ASTNode current = node; + while (current != null) { + if (current instanceof TypeDeclaration) { + return (TypeDeclaration) current; + } + current = current.getParent(); + } + return null; + } + + private static String getFullyQualifiedName(TypeDeclaration typeDecl) { + if (typeDecl.resolveBinding() != null) { + String qualifiedName = typeDecl.resolveBinding().getQualifiedName(); + return qualifiedName.replaceAll("\\.(?=[^\\.]+$)", "\\$"); + } + CompilationUnit cu = (CompilationUnit) typeDecl.getRoot(); + String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : ""; + String typeName = typeDecl.getName().getFullyQualifiedName(); + return packageName.isEmpty() ? typeName : packageName + "." + typeName; + } + +} diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/RewriteRecipeRepository.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/RewriteRecipeRepository.java index 0da6b4ba2d..c003a9fd9e 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/RewriteRecipeRepository.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/RewriteRecipeRepository.java @@ -56,7 +56,6 @@ import org.openrewrite.config.YamlResourceLoader; import org.openrewrite.internal.InMemoryLargeSourceSet; import org.openrewrite.java.JavaParser; -import org.openrewrite.maven.AddDependency; import org.openrewrite.maven.MavenParser; import org.openrewrite.tree.ParseError; import org.slf4j.Logger; diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/RewriteRefactorings.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/RewriteRefactorings.java index 2419f85c78..a74f96479a 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/RewriteRefactorings.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/RewriteRefactorings.java @@ -15,6 +15,7 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Map.Entry; import java.util.Optional; import java.util.UUID; @@ -27,6 +28,7 @@ import org.openrewrite.Parser.Input; import org.openrewrite.Recipe; import org.openrewrite.SourceFile; +import org.openrewrite.config.DeclarativeRecipe; import org.openrewrite.internal.RecipeIntrospectionUtils; import org.openrewrite.java.JavaParser; import org.openrewrite.marker.Range; @@ -154,16 +156,8 @@ private CompletableFuture createRecipe(FixDescriptor d) { .orElseGet(() -> recipeRepo.getRecipe(d.getRecipeId()).thenApply(opt -> opt.orElseThrow()))) .thenApply(r -> { if (d.getParameters() != null) { - for (Entry entry : d.getParameters().entrySet()) { - try { - Field f = r.getClass().getDeclaredField(entry.getKey()); - f.setAccessible(true); - f.set(r, entry.getValue()); - } catch (Exception e) { - log.error("", e);; - } - } - } + setParameters(r, d.getParameters()); + } if (d.getRecipeScope() == RecipeScope.NODE) { if (d.getRangeScope() == null) { throw new IllegalArgumentException("Missing scope AST node!"); @@ -187,4 +181,41 @@ private CompletableFuture createRecipe(FixDescriptor d) { return r; }); } + + /** + * Sets the parameters for a given recipe. If the recipe is a DeclarativeRecipe, + * it iterates over its sub-recipes and sets the parameters for each sub-recipe. + */ + private void setParameters(Recipe recipe, Map parameters) { + if (recipe instanceof DeclarativeRecipe) { + List subRecipes = ((DeclarativeRecipe) recipe).getRecipeList(); + for (Recipe subRecipe : subRecipes) { + setParameters(subRecipe, parameters); + } + } else { + for (Entry entry : parameters.entrySet()) { + try { + Field field = findField(recipe, entry.getKey()); + if (field != null) { + field.setAccessible(true); + field.set(recipe, entry.getValue()); + } + } catch (Exception e) { + log.error("", e);; + } + } + } + } + + private Field findField(Object obj, String fieldName) { + Class clazz = obj.getClass(); + while (clazz != null) { + try { + return clazz.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + return null; + } } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/beans/test/BeanCompletionProviderTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/beans/test/BeanCompletionProviderTest.java new file mode 100644 index 0000000000..2ff7f3977d --- /dev/null +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/beans/test/BeanCompletionProviderTest.java @@ -0,0 +1,362 @@ +/******************************************************************************* + * Copyright (c) 2017, 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.java.beans.test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.ide.vscode.boot.app.SpringSymbolIndex; +import org.springframework.ide.vscode.boot.bootiful.BootLanguageServerTest; +import org.springframework.ide.vscode.boot.bootiful.SymbolProviderTestConf; +import org.springframework.ide.vscode.boot.index.SpringMetamodelIndex; +import org.springframework.ide.vscode.commons.java.IJavaProject; +import org.springframework.ide.vscode.commons.languageserver.java.JavaProjectFinder; +import org.springframework.ide.vscode.commons.protocol.spring.Bean; +import org.springframework.ide.vscode.commons.util.text.LanguageId; +import org.springframework.ide.vscode.languageserver.testharness.Editor; +import org.springframework.ide.vscode.project.harness.BootLanguageServerHarness; +import org.springframework.ide.vscode.project.harness.ProjectsHarness; +import org.springframework.test.context.junit.jupiter.SpringExtension; + + +/** + * @author Udayani V + */ +@ExtendWith(SpringExtension.class) +@BootLanguageServerTest +@Import(SymbolProviderTestConf.class) +public class BeanCompletionProviderTest { + + @Autowired private BootLanguageServerHarness harness; + @Autowired private JavaProjectFinder projectFinder; + @Autowired private SpringMetamodelIndex springIndex; + @Autowired private SpringSymbolIndex indexer; + + private File directory; + private IJavaProject project; + private Bean[] indexedBeans; + private String tempJavaDocUri; + private Bean bean1; + private Bean bean2; + private Bean bean3; + private Bean bean4; + private Bean bean5; + + @BeforeEach + public void setup() throws Exception { + harness.intialize(null); + + directory = new File(ProjectsHarness.class.getResource("/test-projects/test-spring-indexing/").toURI()); + + String projectDir = directory.toURI().toString(); + project = projectFinder.find(new TextDocumentIdentifier(projectDir)).get(); + + CompletableFuture initProject = indexer.waitOperation(); + initProject.get(5, TimeUnit.SECONDS); + + indexedBeans = springIndex.getBeansOfProject(project.getElementName()); + + tempJavaDocUri = directory.toPath().resolve("src/main/java/org/test/TempClass.java").toUri().toString(); + bean1 = new Bean("ownerRepository", "org.springframework.samples.petclinic.owner.OwnerRepository", new Location(tempJavaDocUri, new Range(new Position(1,1), new Position(1, 20))), null, null, null); + bean2 = new Bean("ownerService", "org.springframework.samples.petclinic.owner.OwnerService", new Location(tempJavaDocUri, new Range(new Position(1,1), new Position(1, 20))), null, null, null); + bean3 = new Bean("visitRepository", "org.springframework.samples.petclinic.owner.VisitRepository", new Location(tempJavaDocUri, new Range(new Position(1,1), new Position(1, 20))), null, null, null); + bean4 = new Bean("visitService", "org.springframework.samples.petclinic.owner.VisitService", new Location(tempJavaDocUri, new Range(new Position(1,1), new Position(1, 20))), null, null, null); + bean5 = new Bean("petService", "org.springframework.samples.petclinic.pet.Inner.PetService", new Location(tempJavaDocUri, new Range(new Position(1,1), new Position(1, 20))), null, null, null); + + springIndex.updateBeans(project.getElementName(), new Bean[] {bean1, bean2, bean3, bean4, bean5}); + } + + @AfterEach + public void restoreIndexState() { + this.springIndex.updateBeans(project.getElementName(), indexedBeans); + } + + @Test + public void testBeanCompletion_withMatches() throws Exception { + assertCompletions(getCompletion("owner<*>"), new String[] {"ownerRepository", "ownerService"}, 0, + """ +package org.sample.test; + +import org.springframework.samples.petclinic.owner.OwnerRepository; +import org.springframework.stereotype.Controller; + +@Controller +public class TestBeanCompletionClass { + + private final OwnerRepository ownerRepository; + + TestBeanCompletionClass(OwnerRepository ownerRepository) { + this.ownerRepository = ownerRepository; + } + + public void test() { +ownerRepository<*> + } +} + """); + } + + @Test + public void testBeanCompletion_withoutMatches() throws Exception { + assertCompletions(getCompletion("rand<*>"), new String[] {}, 0, ""); + } + + @Test + public void testBeanCompletion_chooseSecondCompletion() throws Exception { + assertCompletions(getCompletion("owner<*>"), new String[] {"ownerRepository", "ownerService"}, 1, + """ +package org.sample.test; + +import org.springframework.samples.petclinic.owner.OwnerService; +import org.springframework.stereotype.Controller; + +@Controller +public class TestBeanCompletionClass { + + private final OwnerService ownerService; + + TestBeanCompletionClass(OwnerService ownerService) { + this.ownerService = ownerService; + } + + public void test() { +ownerService<*> + } +} + """); + } + + @Test + public void testBeanCompletion_injectInnerClass() throws Exception { + assertCompletions(getCompletion("pet<*>"), new String[] {"petService"}, 0, + """ +package org.sample.test; + +import org.springframework.samples.petclinic.pet.Inner.PetService; +import org.springframework.stereotype.Controller; + +@Controller +public class TestBeanCompletionClass { + + private final Inner.PetService petService; + + TestBeanCompletionClass(Inner.PetService petService) { + this.petService = petService; + } + + public void test() { +petService<*> + } +} + """); + } + + @Test + public void testBeanCompletion_multipleClasses() throws Exception { + String content = """ + package org.sample.test; + + import org.springframework.samples.petclinic.owner.OwnerRepository; + import org.springframework.stereotype.Controller; + + @Controller + public class TestBeanCompletionClass { + private final OwnerRepository ownerRepository; + + TestBeanCompletionClass(OwnerRepository ownerRepository) { + this.ownerRepository = ownerRepository; + } + + public void test() { + } + } + + @Controller + public class TestBeanCompletionSecondClass { + + public void test() { + owner<*> + } + } + """; + + assertCompletions(content, new String[] {"ownerRepository", "ownerService"}, 1, + """ +package org.sample.test; + +import org.springframework.samples.petclinic.owner.OwnerRepository; +import org.springframework.samples.petclinic.owner.OwnerService; +import org.springframework.stereotype.Controller; + +@Controller +public class TestBeanCompletionClass { + private final OwnerRepository ownerRepository; + + TestBeanCompletionClass(OwnerRepository ownerRepository) { + this.ownerRepository = ownerRepository; + } + + public void test() { + } +} + +@Controller +public class TestBeanCompletionSecondClass { + + private final OwnerService ownerService; + + TestBeanCompletionSecondClass(OwnerService ownerService) { + this.ownerService = ownerService; + } + + public void test() { + ownerService<*> + } +} + """); + } + + @Test + public void testBeanCompletion_isNotSpringComponent() throws Exception { + String content = """ + package org.sample.test; + + public class TestBeanCompletionClass { + + public void test() { + owner<*> + } + } + """; + // No suggestions when it is not a spring component + assertCompletions(content, new String[] {}, 0, ""); + } + + @Test + public void testBeanCompletion_isOutsideMethod() throws Exception { + String content = """ + package org.sample.test; + + import org.springframework.stereotype.Controller; + + @Controller + public class TestBeanCompletionClass { + owner<*> + } + """; + assertCompletions(content, new String[] {}, 0, ""); + } + + @Test + public void testBeanCompletion_nestedComponent() throws Exception { + String content = """ +package org.sample.test; + +import org.springframework.stereotype.Component; + +@Component +public class TestBeanCompletionClass { + @Component + public class Inner { + + public void test() { + ownerRe<*> + } + } +} + """; + + assertCompletions(content, new String[] {"ownerRepository"}, 0, + """ +package org.sample.test; + +import org.springframework.samples.petclinic.owner.OwnerRepository; +import org.springframework.stereotype.Component; + +@Component +public class TestBeanCompletionClass { + @Component + public class Inner { + + private final OwnerRepository ownerRepository; + + Inner(OwnerRepository ownerRepository) { + this.ownerRepository = ownerRepository; + } + + public void test() { + ownerRepository<*> + } + } +} + """); + } + + private String getCompletion(String completionLine) { + String content = """ + package org.sample.test; + + import org.springframework.stereotype.Controller; + + @Controller + public class TestBeanCompletionClass { + + public void test() { + """ + + completionLine + "\n" + + """ + } + } + """; + return content; + } + + private void assertCompletions(String completionLine, String[] expectedCompletions, int chosenCompletion, String expectedResult) throws Exception { + assertCompletions(completionLine, expectedCompletions.length, expectedCompletions, chosenCompletion, expectedResult); + } + + private void assertCompletions(String editorContent, int noOfExcpectedCompletions, String[] expectedCompletions, int chosenCompletion, String expectedResult) throws Exception { + Editor editor = harness.newEditor(LanguageId.JAVA, editorContent, tempJavaDocUri); + + List completions = editor.getCompletions(); + assertEquals(noOfExcpectedCompletions, completions.size()); + + if (expectedCompletions != null) { + String[] completionItems = completions.stream() + .map(item -> item.getLabel()) + .toArray(size -> new String[size]); + + assertArrayEquals(expectedCompletions, completionItems); + } + + if (noOfExcpectedCompletions > 0) { + editor.apply(completions.get(chosenCompletion)); + assertEquals(expectedResult, editor.getText()); + } + } + +}