From 5bc38322a2f4a7fac449b39bf375f04774e078a0 Mon Sep 17 00:00:00 2001 From: Martin Lippert Date: Mon, 15 Jul 2024 17:23:09 +0200 Subject: [PATCH] GH-1294: add content-assist for profile annotation values --- .../BootJavaCompletionEngineConfigurer.java | 3 +- .../java/beans/ProfileCompletionProvider.java | 66 +++++ .../test/ProfileCompletionProviderTest.java | 229 ++++++++++++++++++ 3 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/ProfileCompletionProvider.java create mode 100644 headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/beans/test/ProfileCompletionProviderTest.java 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 a24195bdc..15f347bb7 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; 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.DependsOnCompletionProcessor; +import org.springframework.ide.vscode.boot.java.beans.ProfileCompletionProvider; import org.springframework.ide.vscode.boot.java.beans.QualifierCompletionProvider; import org.springframework.ide.vscode.boot.java.data.DataRepositoryCompletionProcessor; import org.springframework.ide.vscode.boot.java.handlers.BootJavaCompletionEngine; @@ -118,7 +119,7 @@ public class BootJavaCompletionEngineConfigurer { 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)))); 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/ProfileCompletionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/ProfileCompletionProvider.java new file mode 100644 index 000000000..85cc4d0dc --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/ProfileCompletionProvider.java @@ -0,0 +1,66 @@ +/******************************************************************************* + * Copyright (c) 2024 Broadcom + * 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 - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.java.beans; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +import org.springframework.ide.vscode.boot.index.SpringMetamodelIndex; +import org.springframework.ide.vscode.boot.java.Annotations; +import org.springframework.ide.vscode.boot.java.annotations.AnnotationAttributeCompletionProvider; +import org.springframework.ide.vscode.commons.java.IJavaProject; +import org.springframework.ide.vscode.commons.protocol.spring.Bean; + +/** + * @author Martin Lippert + */ +public class ProfileCompletionProvider implements AnnotationAttributeCompletionProvider { + + private final SpringMetamodelIndex springIndex; + + public ProfileCompletionProvider(SpringMetamodelIndex springIndex) { + this.springIndex = springIndex; + } + + @Override + public List getCompletionCandidates(IJavaProject project) { + + Bean[] beans = this.springIndex.getBeansOfProject(project.getElementName()); + + return findAllProfiles(beans) + .distinct() + .toList(); + } + + private Stream findAllProfiles(Bean[] beans) { + + Stream profilesFromBeans = Arrays.stream(beans) + // annotations from beans themselves + .flatMap(bean -> Arrays.stream(bean.getAnnotations())) + .filter(annotation -> Annotations.PROFILE.equals(annotation.getAnnotationType())) + .filter(annotation -> annotation.getAttributes() != null && annotation.getAttributes().containsKey("value")) + .flatMap(annotation -> Arrays.stream(annotation.getAttributes().get("value"))); + + Stream profilesFromInjectionPoints = Arrays.stream(beans) + // annotations from beans themselves + .filter(bean -> bean.getInjectionPoints() != null) + .flatMap(bean -> Arrays.stream(bean.getInjectionPoints())) + .filter(injectionPoint -> injectionPoint.getAnnotations() != null) + .flatMap(injectionPoint -> Arrays.stream(injectionPoint.getAnnotations())) + .filter(annotation -> Annotations.PROFILE.equals(annotation.getAnnotationType())) + .filter(annotation -> annotation.getAttributes() != null && annotation.getAttributes().containsKey("value")) + .flatMap(annotation -> Arrays.stream(annotation.getAttributes().get("value"))); + + return Stream.concat(profilesFromBeans, profilesFromInjectionPoints); + } + +} diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/beans/test/ProfileCompletionProviderTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/beans/test/ProfileCompletionProviderTest.java new file mode 100644 index 000000000..d5383bae2 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/beans/test/ProfileCompletionProviderTest.java @@ -0,0 +1,229 @@ +/******************************************************************************* + * Copyright (c) 2024 Broadcom + * 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 - 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.Map; +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.AnnotationMetadata; +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 Martin Lippert + */ +@ExtendWith(SpringExtension.class) +@BootLanguageServerTest +@Import(SymbolProviderTestConf.class) +public class ProfileCompletionProviderTest { + + @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 String[] allProposals; + + @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(); + AnnotationMetadata annotationBean1 = new AnnotationMetadata("org.springframework.context.annotation.Profile", false, Map.of("value", new String[] {"prof1", "prof2"})); + AnnotationMetadata annotationBean2 = new AnnotationMetadata("org.springframework.context.annotation.Profile", false, Map.of("value", new String[] {"prof3"})); + + bean1 = new Bean("bean1", "type1", new Location(tempJavaDocUri, new Range(new Position(1,1), new Position(1, 20))), null, null, new AnnotationMetadata[] {annotationBean1}); + bean2 = new Bean("bean2", "type2", new Location(tempJavaDocUri, new Range(new Position(1,1), new Position(1, 20))), null, null, new AnnotationMetadata[] {annotationBean2}); + + springIndex.updateBeans(project.getElementName(), new Bean[] {bean1, bean2}); + + allProposals = new String[] {"prof1", "prof2", "prof3"}; + } + + @AfterEach + public void restoreIndexState() { + this.springIndex.updateBeans(project.getElementName(), indexedBeans); + } + + @Test + public void testProfileCompletionWithoutQuotesWithoutPrefix() throws Exception { + assertCompletions("@Profile(<*>)", allProposals, 0, "@Profile(\"prof1\"<*>)"); + } + + @Test + public void testProfileCompletionWithoutQuotesWithPrefix() throws Exception { + assertCompletions("@Profile(prof1<*>)", new String[] {"prof1"}, 0, "@Profile(\"prof1\"<*>)"); + } + + @Test + public void testProfileCompletionWithoutQuotesWithAttributeName() throws Exception { + assertCompletions("@Profile(value=<*>)", allProposals, 0, "@Profile(value=\"prof1\"<*>)"); + } + + @Test + public void testProfileCompletionInsideOfQuotesWithoutPrefix() throws Exception { + assertCompletions("@Profile(\"<*>\")", allProposals, 0, "@Profile(\"prof1<*>\")"); + } + + @Test + public void testProfileCompletionInsideOfQuotesWithPrefix() throws Exception { + assertCompletions("@Profile(\"prof1<*>\")", new String[] {"prof1"}, 0, "@Profile(\"prof1<*>\")"); + } + + @Test + public void testProfileCompletionInsideOfQuotesAndArrayWithPrefix() throws Exception { + assertCompletions("@Profile({\"pr<*>\"})", allProposals, 0, "@Profile({\"prof1<*>\"})"); + } + + @Test + public void testProfileCompletionInsideOfQuotesWithoutPrefixInsideArray() throws Exception { + assertCompletions("@Profile({\"<*>\"})", allProposals, 0, "@Profile({\"prof1<*>\"})"); + } + + @Test + public void testProfileCompletionWithoutQuotesWithoutPrefixInsideArray() throws Exception { + assertCompletions("@Profile({<*>})", allProposals, 0, "@Profile({\"prof1\"<*>})"); + } + + @Test + public void testProfileCompletionInsideOfArrayBehindExistingElement() throws Exception { + assertCompletions("@Profile({\"prof1\",<*>})", new String[] {"prof2", "prof3"}, 0, "@Profile({\"prof1\",\"prof2\"<*>})"); + } + + @Test + public void testProfileCompletionInsideOfArrayInFrontOfExistingElement() throws Exception { + assertCompletions("@Profile({<*>\"prof1\"})", new String[] {"prof2", "prof3"}, 0, "@Profile({\"prof2\",<*>\"prof1\"})"); + } + + @Test + public void testProfileCompletionInsideOfArrayBetweenExistingElements() throws Exception { + assertCompletions("@Profile({\"prof1\",<*>\"prof2\"})", new String[] {"prof3"}, 0, "@Profile({\"prof1\",\"prof3\",<*>\"prof2\"})"); + } + + @Test + public void testProfileCompletionInsideOfQuotesWithPrefixButWithoutMatches() throws Exception { + assertCompletions("@Profile(\"XXX<*>\")", 0, null); + } + + @Test + public void testProfileCompletionOutsideOfAnnotation1() throws Exception { + assertCompletions("@Profile(\"XXX\")<*>", 0, null); + } + + @Test + public void testProfileCompletionOutsideOfAnnotation2() throws Exception { + assertCompletions("@Profile<*>(\"XXX\")", 0, null); + } + + @Test + public void testProfileCompletionInsideOfQuotesWithPrefixAndReplacedPostfix() throws Exception { + assertCompletions("@Profile(\"pro<*>xxx\")", allProposals, 0, "@Profile(\"prof1<*>\")"); + } + + private void assertCompletions(String completionLine, int noOfExpectedCompletions, String expectedCompletedLine) throws Exception { + assertCompletions(completionLine, noOfExpectedCompletions, null, 0, expectedCompletedLine); + } + + private void assertCompletions(String completionLine, String[] expectedCompletions, int chosenCompletion, String expectedCompletedLine) throws Exception { + assertCompletions(completionLine, expectedCompletions.length, expectedCompletions, chosenCompletion, expectedCompletedLine); + } + + + private void assertCompletions(String completionLine, int noOfExcpectedCompletions, String[] expectedCompletions, int chosenCompletion, String expectedCompletedLine) throws Exception { + String editorContent = """ + package org.test; + + import org.springframework.stereotype.Component; + import org.springframework.context.annotation.Profile; + + @Component + """ + + completionLine + "\n" + + """ + public class TestDependsOnClass { + } + """; + + 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(""" + package org.test; + + import org.springframework.stereotype.Component; + import org.springframework.context.annotation.Profile; + + @Component + """ + expectedCompletedLine + "\n" + + """ + public class TestDependsOnClass { + } + """, editor.getText()); + } + } + + +}