From 41fa3f4a1225b668644546b09d6deb8c13ed8153 Mon Sep 17 00:00:00 2001 From: vudayani Date: Thu, 3 Oct 2024 11:27:30 +0530 Subject: [PATCH] Inject Bean completion proposal via Rewrite recipes --- .../completion/ICompletionProposal.java | 6 + .../VscodeCompletionEngineAdapter.java | 5 +- .../util/SimpleLanguageServer.java | 2 +- .../spring/CommentOutSpringPropertyKey.java | 109 ++++ .../vscode/commons/rewrite/ORDocUtils.java | 41 +- .../commons/rewrite/java/AddFieldRecipe.java | 135 +++++ .../java/ConstructorInjectionRecipe.java | 316 +++++++++++ .../java/InjectBeanCompletionRecipe.java | 55 ++ .../CommentOutSpringPropertyKeyTest.java | 119 +++++ .../rewrite/java/AddFieldRecipeTest.java | 332 ++++++++++++ .../java/ConstructorInjectionRecipeTest.java | 489 ++++++++++++++++++ .../java/InjectBeanCompletionRecipeTest.java | 99 ++++ .../languageserver/testharness/Editor.java | 13 + .../testharness/LanguageServerHarness.java | 2 +- headless-services/commons/pom.xml | 8 + .../BootJavaCompletionEngineConfigurer.java | 7 +- .../java/beans/BeanCompletionProposal.java | 87 ++++ .../java/beans/BeanCompletionProvider.java | 143 +++++ .../java/rewrite/RewriteRefactorings.java | 71 ++- .../test/BeanCompletionProviderTest.java | 362 +++++++++++++ 20 files changed, 2382 insertions(+), 19 deletions(-) create mode 100644 headless-services/commons/commons-rewrite/src/main/java/org/openrewrite/java/spring/CommentOutSpringPropertyKey.java create mode 100644 headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/AddFieldRecipe.java create mode 100644 headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/ConstructorInjectionRecipe.java create mode 100644 headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/InjectBeanCompletionRecipe.java create mode 100644 headless-services/commons/commons-rewrite/src/test/java/org/openrewrite/java/spring/CommentOutSpringPropertyKeyTest.java create mode 100644 headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/AddFieldRecipeTest.java create mode 100644 headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/ConstructorInjectionRecipeTest.java create mode 100644 headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/InjectBeanCompletionRecipeTest.java create mode 100644 headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeanCompletionProposal.java create mode 100644 headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeanCompletionProvider.java create mode 100644 headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/beans/test/BeanCompletionProviderTest.java diff --git a/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/completion/ICompletionProposal.java b/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/completion/ICompletionProposal.java index 5de922a57..474cb39e4 100644 --- a/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/completion/ICompletionProposal.java +++ b/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/completion/ICompletionProposal.java @@ -14,6 +14,7 @@ package org.springframework.ide.vscode.commons.languageserver.completion; import java.util.Optional; import java.util.function.Supplier; +import org.eclipse.lsp4j.Command; import org.eclipse.lsp4j.CompletionItemKind; import org.springframework.ide.vscode.commons.util.Renderable; @@ -57,4 +58,9 @@ public interface ICompletionProposal { } }; } + + default Optional getCommand() { + return Optional.empty(); + } + } diff --git a/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/completion/VscodeCompletionEngineAdapter.java b/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/completion/VscodeCompletionEngineAdapter.java index 8090636be..a04ea0347 100644 --- a/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/completion/VscodeCompletionEngineAdapter.java +++ b/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/completion/VscodeCompletionEngineAdapter.java @@ -300,6 +300,7 @@ public class VscodeCompletionEngineAdapter implements VscodeCompletionEngine { } List commands = new ArrayList<>(2); + completion.getCommand().ifPresent(commands::add); if (LspClient.currentClient() != LspClient.Client.ECLIPSE) { /* * Eclipse client always send completionItem resolve request before applying completion. @@ -352,7 +353,9 @@ public class VscodeCompletionEngineAdapter implements VscodeCompletionEngine { } } if (subCommands.size() == 1) { - item.setCommand((Command)subCommands.get(0)); + Object o = subCommands.get(0); + Command subCommand = o instanceof Command ? (Command) o : GSON.fromJson(o instanceof JsonElement ? (JsonElement) o : GSON.toJsonTree(o), Command.class); + item.setCommand(subCommand); } else if (subCommands.isEmpty()) { item.setCommand(null); } diff --git a/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/util/SimpleLanguageServer.java b/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/util/SimpleLanguageServer.java index 21569701d..d1e877f44 100644 --- a/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/util/SimpleLanguageServer.java +++ b/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/util/SimpleLanguageServer.java @@ -315,7 +315,7 @@ public final class SimpleLanguageServer implements Sts4LanguageServer, SpringInd if (CODE_ACTION_COMMAND_ID.equals(params.getCommand())) { Assert.isLegal(params.getArguments().size()==2); QuickfixResolveParams quickfixParams = new QuickfixResolveParams( - ((JsonPrimitive)params.getArguments().get(0)).getAsString(), params.getArguments().get(1) + params.getArguments().get(0) instanceof JsonPrimitive ? ((JsonPrimitive)params.getArguments().get(0)).getAsString() : params.getArguments().get(0).toString() , params.getArguments().get(1) ); return quickfixResolve(quickfixParams) .flatMap((QuickfixEdit edit) -> { diff --git a/headless-services/commons/commons-rewrite/src/main/java/org/openrewrite/java/spring/CommentOutSpringPropertyKey.java b/headless-services/commons/commons-rewrite/src/main/java/org/openrewrite/java/spring/CommentOutSpringPropertyKey.java new file mode 100644 index 000000000..4a11c6834 --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/main/java/org/openrewrite/java/spring/CommentOutSpringPropertyKey.java @@ -0,0 +1,109 @@ +/* + * Copyright 2021 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.java.spring; + +import java.util.Objects; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.openrewrite.*; +import org.openrewrite.properties.tree.Properties; +import org.openrewrite.yaml.tree.Yaml; + +public class CommentOutSpringPropertyKey extends Recipe { + + @Override + public String getDisplayName() { + return "Comment out Spring properties"; + } + + @Override + public String getDescription() { + return "Add comment to specified Spring properties, and comment out the property."; + } + + @Option(displayName = "Property key", + description = "The name of the property key to comment out.", + example = "management.metrics.binders.files.enabled") + String propertyKey; + + @Option(displayName = "Comment", + description = "Comment to replace the property key.", + example = "This property is deprecated and no longer applicable starting from Spring Boot 3.0.x") + String comment; + + + public CommentOutSpringPropertyKey(String propertyKey, String comment) { + super(); + this.propertyKey = propertyKey; + this.comment = comment; + } + + @Override + public TreeVisitor getVisitor() { + Recipe changeProperties = new org.openrewrite.properties.AddPropertyComment(propertyKey, comment, true); + Recipe changeYaml = new org.openrewrite.yaml.CommentOutProperty(propertyKey, comment, true); + return new TreeVisitor() { + @Override + public @Nullable Tree preVisit(@NonNull Tree tree, ExecutionContext ctx) { + stopAfterPreVisit(); + if (tree instanceof Properties.File) { + return changeProperties.getVisitor().visit(tree, ctx); + } else if (tree instanceof Yaml.Documents) { + return changeYaml.getVisitor().visit(tree, ctx); + } + return tree; + } + }; + } + + public String getPropertyKey() { + return propertyKey; + } + + public void setPropertyKey(String propertyKey) { + this.propertyKey = propertyKey; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = super.hashCode(); + result = prime * result + Objects.hash(comment, propertyKey); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (!super.equals(obj)) + return false; + if (getClass() != obj.getClass()) + return false; + CommentOutSpringPropertyKey other = (CommentOutSpringPropertyKey) obj; + return Objects.equals(comment, other.comment) && Objects.equals(propertyKey, other.propertyKey); + } + +} 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 423a0fad5..7e3db3dfe 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.CreateFile; 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; @@ -71,6 +72,44 @@ public class ORDocUtils { return Optional.empty(); } + + 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); @@ -172,7 +211,7 @@ public class ORDocUtils { addToWorkspaceEdit(documents, docUri, oldContent, newContent, changeAnnotationId, we); } } - + public static void addToWorkspaceEdit(SimpleTextDocumentService documents, String docUri, String oldContent, String newContent, String changeAnnotationId, WorkspaceEdit we) { if(oldContent == null) { createNewFileEdit(docUri, newContent, 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 000000000..e6dde257c --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/AddFieldRecipe.java @@ -0,0 +1,135 @@ +/******************************************************************************* + * 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.Collections; +import java.util.List; + +import org.jspecify.annotations.NonNull; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.Tree; +import org.openrewrite.TreeVisitor; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JRightPadded; +import org.openrewrite.java.tree.JavaType; +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 AddFieldRecipe extends Recipe { + + @Override + public String getDisplayName() { + return "Add field"; + } + + @Override + public String getDescription() { + return "Add field desccription."; + } + + String fullyQualifiedName; + + @NonNull + 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); + + @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) { + J.VariableDeclarations newFieldDecl = new J.VariableDeclarations( + Tree.randomId(), + Space.build("\n\n", Collections.emptyList()), + Markers.EMPTY, + Collections.emptyList(), + List.of( + new J.Modifier(Tree.randomId(), Space.EMPTY, Markers.EMPTY, "private", J.Modifier.Type.Private, Collections.emptyList()), + new J.Modifier(Tree.randomId(), Space.SINGLE_SPACE, Markers.EMPTY, "final", J.Modifier.Type.Final, Collections.emptyList()) + ), + TypeTree.build(fieldType), + null, + Collections.emptyList(), + List.of(JRightPadded.build(new J.VariableDeclarations.NamedVariable( + Tree.randomId(), + Space.EMPTY, + Markers.EMPTY, + new J.Identifier(Tree.randomId(), Space.EMPTY, Markers.EMPTY, Collections.emptyList(), fieldName, fullyQualifiedType, null), + Collections.emptyList(), + null, + null + ))) + ); + Statement formattedNewFieldDecl = autoFormat(classDecl.getBody().withStatements(List.of(newFieldDecl)), ctx, getCursor()).getStatements().get(0); + List newStatements = new ArrayList<>(classDecl.getBody().getStatements().size() + 1); + newStatements.add(formattedNewFieldDecl); + newStatements.addAll(classDecl.getBody().getStatements()); + classDecl = classDecl.withBody(classDecl.getBody().withStatements(newStatements)); + + + 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 000000000..43c05bc1b --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/ConstructorInjectionRecipe.java @@ -0,0 +1,316 @@ +/******************************************************************************* + * 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.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.jspecify.annotations.NonNull; +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.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.Assignment; +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.JLeftPadded; +import org.openrewrite.java.tree.JRightPadded; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.JavaType.FullyQualified; +import org.openrewrite.java.tree.JavaType.ShallowClass; +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 + String fullyQualifiedName; + + @NonNull + String fieldName; + + @NonNull + 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 newParams = new ArrayList<>(md.getParameters().stream().filter(s -> !(s instanceof J.Empty)).toList()); + J.VariableDeclarations vd = new J.VariableDeclarations( + Tree.randomId(), + newParams.isEmpty() ? Space.EMPTY : Space.SINGLE_SPACE, + Markers.EMPTY, + Collections.emptyList(), + Collections.emptyList(), + TypeTree.build(methodType), + null, + Collections.emptyList(), + List.of(JRightPadded.build(new J.VariableDeclarations.NamedVariable( + Tree.randomId(), + Space.SINGLE_SPACE, + Markers.EMPTY, + createFieldNameIdentifier(), + Collections.emptyList(), + null, + null + ))) + ); + newParams.add(vd); + md = md.withParameters(newParams); + updateCursor(md); + + // noinspection ConstantConditions + ShallowClass type = JavaType.ShallowClass.build(methodType); + J.FieldAccess fa = new J.FieldAccess(Tree.randomId(), Space.EMPTY, Markers.EMPTY, new J.Identifier(Tree.randomId(), Space.EMPTY, Markers.EMPTY, Collections.emptyList(), "this", md.getMethodType().getDeclaringType(), null), JLeftPadded.build(createFieldNameIdentifier()), type); + Assignment assign = new J.Assignment(Tree.randomId(), Space.build("\n", Collections.emptyList()), Markers.EMPTY, fa, JLeftPadded.build(createFieldNameIdentifier()), type); + List newStatements = new ArrayList<>(md.getBody().getStatements()); + newStatements.add(assign); + md = md.withBody(autoFormat(md.getBody().withStatements(newStatements), p, getCursor())); + } + return md; + } + + private J.Identifier createFieldNameIdentifier() { + return new J.Identifier(Tree.randomId(), Space.EMPTY, Markers.EMPTY, Collections.emptyList(), fieldName, JavaType.ShallowClass.build(methodType), null); + } + + } + + 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 000000000..bf2b10124 --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/InjectBeanCompletionRecipe.java @@ -0,0 +1,55 @@ +/******************************************************************************* + * 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; + +/** + * @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."; + } + + String fullyQualifiedName; + + String fieldName; + + 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/openrewrite/java/spring/CommentOutSpringPropertyKeyTest.java b/headless-services/commons/commons-rewrite/src/test/java/org/openrewrite/java/spring/CommentOutSpringPropertyKeyTest.java new file mode 100644 index 000000000..ab27885b0 --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/test/java/org/openrewrite/java/spring/CommentOutSpringPropertyKeyTest.java @@ -0,0 +1,119 @@ +/* + * Copyright 2021 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.java.spring; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.properties.tree.Properties; +import org.openrewrite.test.RewriteTest; +import org.openrewrite.yaml.tree.Yaml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.openrewrite.properties.Assertions.properties; +import static org.openrewrite.yaml.Assertions.yaml; + +class CommentOutSpringPropertyKeyTest implements RewriteTest { + + @DocumentExample + @Test + void yamlComment() { + rewriteRun( + spec -> spec.recipe(new CommentOutSpringPropertyKey("server.port", "This property has been removed.")), + //language=yaml + yaml( + "server.port: 8080", + """ + # This property has been removed. + # server.port: 8080 + """) + ); + } + + @Test + void multipleYamlComments() { + rewriteRun( + spec -> spec.recipes( + new CommentOutSpringPropertyKey("test.propertyKey1", "my comment 1"), + new CommentOutSpringPropertyKey("test.propertyKey2", "my comment 2") + ), + //language=yaml + yaml( + """ + test: + propertyKey1: xxx + propertyKey2: yyy + propertyKey3: zzz + """, + """ + test: + # my comment 2 + # my comment 1 + # propertyKey1: xxx + # propertyKey2: yyy + propertyKey3: zzz + """, + spec -> spec.path("application.yaml") + .afterRecipe(file -> + assertThat( + ((Yaml.Mapping) + ((Yaml.Mapping) file.getDocuments().get(0) + .getBlock()).getEntries().get(0) + .getValue()).getEntries().get(0) + .getPrefix()) + .isEqualTo( + """ + + # my comment 2 + # my comment 1 + # propertyKey1: xxx + # propertyKey2: yyy + \ + """ + ) + ) + ) + ); + } + + @Test + void multiplePropertiesComments() { + rewriteRun( + spec -> spec.recipes( + new CommentOutSpringPropertyKey("test.propertyKey1", "my comment 1"), + new CommentOutSpringPropertyKey("test.propertyKey2", "my comment 2") + ), + //language=properties + properties( + """ + test.propertyKey1=xxx + test.propertyKey2=yyy + test.propertyKey3=zzz + """, + """ + # my comment 1 + # test.propertyKey1=xxx + # my comment 2 + # test.propertyKey2=yyy + test.propertyKey3=zzz + """, + spec -> spec.path("application.properties") + .afterRecipe(file -> + assertThat(((Properties.Comment) file.getContent().get(3)).getMessage()) + .isEqualTo(" test.propertyKey2=yyy")) + ) + ); + } +} 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 000000000..6385bdce9 --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/AddFieldRecipeTest.java @@ -0,0 +1,332 @@ +/******************************************************************************* + * 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; + +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 expectedSourceStr, String dependsOn) { + JavaParser javaParser = JavaParser.fromJavaVersion().dependsOn(dependsOn).build(); + + List list = javaParser.parse(beforeSourceStr).toList(); + SourceFile beforeSource = list.get(0); + + assertThat(beforeSource.printAll()).isEqualTo(beforeSourceStr); + + 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 expectedSourceStr = """ + package com.example.demo; + + class FooBar { + + private final OwnerRepository ownerRepository; + + public void test() { + ownerR + + } + + } + """; + + String dependsOn = """ + package com.example.demo; + public interface OwnerRepository{} + """; + + Recipe recipe = new AddFieldRecipe("com.example.demo.OwnerRepository", "com.example.demo.FooBar"); + runRecipeAndAssert(recipe, beforeSourceStr, expectedSourceStr, dependsOn); + } + + @Test + void addFieldAndImport() { + + String beforeSourceStr = """ + package com.example.demo; + + class FooBar { + + public void test() { + ownerR + + } + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.OwnerRepository; + + class FooBar { + + private final OwnerRepository ownerRepository; + + public void test() { + ownerR + + } + + } + """; + + String dependsOn = """ + package com.example.test; + public interface OwnerRepository{} + """; + + Recipe recipe = new AddFieldRecipe("com.example.test.OwnerRepository", "com.example.demo.FooBar"); + runRecipeAndAssert(recipe, beforeSourceStr, expectedSourceStr, dependsOn); + } + + @Test + void addNestedField() { + + String beforeSourceStr = """ + package com.example.demo; + + class FooBar { + + public void test() { + ownerR + + } + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + + import com.example.test.Inner.OwnerRepository; + + class FooBar { + + private final Inner.OwnerRepository ownerRepository; + + public void test() { + ownerR + + } + + } + """; + + 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, expectedSourceStr, dependsOn); + } + + @Test + void addToNestedComponent() { + + String beforeSourceStr = """ + package com.example.demo; + + class FooBar { + class Inner { + + public void test() { + ownerR + } + } + + } + """; + + String expectedSourceStr = """ + package com.example.demo; + +import com.example.test.OwnerRepository; + +class FooBar { + class Inner { + + private final OwnerRepository ownerRepository; + + public void test() { + ownerR + } + } + + } + """; + + 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, expectedSourceStr, dependsOn); + } + + @Test + void addFieldToFirstClass() { + + String beforeSourceStr = """ + package com.example.demo; + + class FooBar { + + public void test() { + ownerR + + } + + } + 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() { + ownerR + + } + + } + 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, 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 expectedSourceStr = """ + package com.example.demo; + + import com.example.test.Inner.OwnerRepository; +import org.springframework.stereotype.Component; + +@Component + class FooBar { + + public void test() { + ownerR + + } + + } + @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, 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 000000000..4b1d08769 --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/ConstructorInjectionRecipeTest.java @@ -0,0 +1,489 @@ +/******************************************************************************* + * 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; + +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 expectedSourceStr, String dependsOn) { + JavaParser javaParser = JavaParser.fromJavaVersion().dependsOn(dependsOn).build(); + + List list = javaParser.parse(beforeSourceStr).toList(); + SourceFile beforeSource = list.get(0); + + assertThat(beforeSource.printAll()).isEqualTo(beforeSourceStr); + + 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 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, 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 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, 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 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, 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 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, 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 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, 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 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, 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 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, 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 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, 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 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, 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 000000000..b3e900769 --- /dev/null +++ b/headless-services/commons/commons-rewrite/src/test/java/org/springframework/ide/vscode/commons/rewrite/java/InjectBeanCompletionRecipeTest.java @@ -0,0 +1,99 @@ +/******************************************************************************* + * 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; + +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 expectedSourceStr, String dependsOn) { + JavaParser javaParser = JavaParser.fromJavaVersion().dependsOn(dependsOn).build(); + + List list = javaParser.parse(beforeSourceStr).toList(); + SourceFile beforeSource = list.get(0); + + assertThat(beforeSource.printAll()).isEqualTo(beforeSourceStr); + + 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 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, expectedSourceStr, dependsOn); + } + + +} diff --git a/headless-services/commons/language-server-test-harness/src/main/java/org/springframework/ide/vscode/languageserver/testharness/Editor.java b/headless-services/commons/language-server-test-harness/src/main/java/org/springframework/ide/vscode/languageserver/testharness/Editor.java index 3079f0bbb..4f7e71c1a 100644 --- a/headless-services/commons/language-server-test-harness/src/main/java/org/springframework/ide/vscode/languageserver/testharness/Editor.java +++ b/headless-services/commons/language-server-test-harness/src/main/java/org/springframework/ide/vscode/languageserver/testharness/Editor.java @@ -546,10 +546,23 @@ public class Editor { } } + Position beforeCmdCursorPosition = getCursor(); + String beforeCmd = doc.getText(); + // Apply command if (completion.getCommand() != null) { harness.executeCommand(completion.getCommand()); + if (beforeCmd.length() != getRawText().length()) { + TextDocument beforeDoc = new TextDocument(doc.getUri(), doc.getLanguageId()); + beforeDoc.setText(beforeCmd); + int beforeOffset = beforeDoc.toOffset(beforeCmdCursorPosition); + TextDocument changedDoc = new TextDocument(doc.getUri(), doc.getLanguageId()); + changedDoc.setText(doc.getText()); + int newOffset = beforeOffset + (getRawText().length() - beforeCmd.length()); + setCursor(changedDoc.toPosition(newOffset)); + } } + } private String getInsertText(CompletionItem completion) { diff --git a/headless-services/commons/language-server-test-harness/src/main/java/org/springframework/ide/vscode/languageserver/testharness/LanguageServerHarness.java b/headless-services/commons/language-server-test-harness/src/main/java/org/springframework/ide/vscode/languageserver/testharness/LanguageServerHarness.java index 88d43bb8e..f18853cc5 100644 --- a/headless-services/commons/language-server-test-harness/src/main/java/org/springframework/ide/vscode/languageserver/testharness/LanguageServerHarness.java +++ b/headless-services/commons/language-server-test-harness/src/main/java/org/springframework/ide/vscode/languageserver/testharness/LanguageServerHarness.java @@ -909,8 +909,8 @@ public class LanguageServerHarness { for (TextEdit textEdit : docEdit.getEdits()) { Range range = textEdit.getRange(); edits.replace(workingDocument.toOffset(range.getStart()), workingDocument.toOffset(range.getEnd()), textEdit.getNewText()); - edits.apply(workingDocument); } + edits.apply(workingDocument); Editor editor = getOpenEditor(uri); if (editor!=null) { editor.setRawText(workingDocument.get()); diff --git a/headless-services/commons/pom.xml b/headless-services/commons/pom.xml index 97dd0295f..5c735f1d2 100644 --- a/headless-services/commons/pom.xml +++ b/headless-services/commons/pom.xml @@ -131,6 +131,14 @@ + + + org.openrewrite + rewrite-bom + 8.42.5 + pom + import + org.openrewrite.recipe rewrite-recipe-bom 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 db29f21b5..b0accc393 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 @@ -26,6 +26,7 @@ import org.springframework.ide.vscode.boot.java.annotations.AnnotationAttributeC import org.springframework.ide.vscode.boot.java.annotations.AnnotationHierarchies; import org.springframework.ide.vscode.boot.java.beans.BeanNamesCompletionProcessor; import org.springframework.ide.vscode.boot.java.beans.BeanTypesCompletionProcessor; +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; @@ -38,6 +39,7 @@ import org.springframework.ide.vscode.boot.java.cron.CronExpressionCompletionPro 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; @@ -113,7 +115,8 @@ public class BootJavaCompletionEngineConfigurer { @Qualifier("adHocProperties") ProjectBasedPropertyIndexProvider adHocProperties, JavaSnippetManager snippetManager, CompilationUnitCache cuCache, - SpringMetamodelIndex springIndex) { + SpringMetamodelIndex springIndex, + RewriteRefactorings rewriteRefactorings ) { SpringPropertyIndexProvider indexProvider = params.indexProvider; JavaProjectFinder javaProjectFinder = params.projectFinder; @@ -168,6 +171,8 @@ public class BootJavaCompletionEngineConfigurer { providers.put(Annotations.SCHEDULED, new AnnotationAttributeCompletionProcessor(javaProjectFinder, Map.of( "cron", new CronExpressionCompletionProvider()))); + providers.put(Annotations.BEAN, new BeanCompletionProvider(javaProjectFinder, springIndex, rewriteRefactorings)); + return new BootJavaCompletionEngine(cuCache, providers, snippetManager); } 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 000000000..704c6757e --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeanCompletionProposal.java @@ -0,0 +1,87 @@ +/******************************************************************************* + * 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 org.eclipse.lsp4j.Command; +import org.eclipse.lsp4j.CompletionItemKind; +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.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.Renderables; +import org.springframework.ide.vscode.commons.util.text.IDocument; + +/** + * @author Udayani V + * @author Alex Boyko + */ +public class BeanCompletionProposal implements ICompletionProposal { + + private DocumentEdits edits; + private IDocument doc; + private String beanId; + private String beanType; + private String className; + private RewriteRefactorings rewriteRefactorings; + + public BeanCompletionProposal(DocumentEdits edits, IDocument doc, String beanId, String beanType, String className, + RewriteRefactorings rewriteRefactorings) { + this.edits = edits; + this.doc = doc; + this.beanId = beanId; + this.beanType = beanType; + this.className = className; + this.rewriteRefactorings = rewriteRefactorings; + } + + @Override + public String getLabel() { + return this.beanId; + } + + @Override + public CompletionItemKind getKind() { + return CompletionItemKind.Constructor; + } + + @Override + public DocumentEdits getTextEdit() { + return edits; + } + + @Override + public String getDetail() { + return "Autowire a bean"; + } + + @Override + public Renderable getDocumentation() { + return Renderables.text( + "Inject bean `%s` of type `%s` as a constructor parameter and add corresponding field".formatted(beanId, beanType)); + } + + @Override + public Optional getCommand() { + FixDescriptor f = new FixDescriptor(InjectBeanCompletionRecipe.class.getName(), List.of(this.doc.getUri()),"Inject bean completions") + .withParameters(Map.of("fullyQualifiedName", beanType, "fieldName", beanId, "classFqName", className)) + .withRecipeScope(RecipeScope.NODE); + return Optional.of(rewriteRefactorings.createFixCommand("Inject bean '%s'".formatted(beanId), f)); + } + + +} 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 000000000..7da08ee57 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeanCompletionProvider.java @@ -0,0 +1,143 @@ +/******************************************************************************* + * 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.Annotation; +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.eclipse.jdt.core.dom.VariableDeclaration; +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) { + if (node instanceof SimpleName) { + try { + // Don't look at anything inside Annotation or VariableDelcaration node + for (ASTNode n = node; n != null; n = n.getParent()) { + if (n instanceof Annotation + || n instanceof VariableDeclaration) { + return; + } + } + + Optional optionalProject = this.javaProjectFinder.find(doc.getId()); + if (optionalProject.isEmpty()) { + return; + } + + IJavaProject project = optionalProject.get(); + TypeDeclaration topLevelClass = findParentClass(node); + if (topLevelClass == null) { + return; + } + + if (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(), + bean.getType(), className, 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/RewriteRefactorings.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/RewriteRefactorings.java index 2419f85c7..1dfba609f 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.net.URI; 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; @@ -22,11 +23,13 @@ import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import org.eclipse.lsp4j.CodeAction; +import org.eclipse.lsp4j.Command; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.WorkspaceEdit; 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; @@ -82,12 +85,23 @@ public class RewriteRefactorings implements CodeActionResolver, QuickfixHandler @Override public Mono createEdits(Object p) { - if (p instanceof JsonElement) { - return Mono.fromFuture(createEdit((JsonElement) p).thenApply(we -> new QuickfixEdit(we, null))); + if (p instanceof JsonElement je) { + return Mono.fromFuture(createEdit(je).thenApply(we -> new QuickfixEdit(we, null))); + } else { + return Mono.fromFuture(createEdit(gson.toJsonTree(p)).thenApply(we -> new QuickfixEdit(we, null))); } - return null; } - + + public Command createFixCommand(String title, FixDescriptor f) { + List args = new ArrayList<>(3); + args.add(RewriteRefactorings.REWRITE_RECIPE_QUICKFIX); + args.add(gson.toJsonTree(f)); + return new Command( + title, + server.CODE_ACTION_COMMAND_ID, + args + ); + } @Override public CompletableFuture resolve(CodeAction codeAction) { @@ -154,16 +168,8 @@ public class RewriteRefactorings implements CodeActionResolver, QuickfixHandler .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 +193,41 @@ public class RewriteRefactorings implements CodeActionResolver, QuickfixHandler 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 000000000..880bbdb58 --- /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, false); + 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, false); + 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, false); + 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, false); + 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, false); + + 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()); + } + } + +}