diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootLanguageServerBootApp.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootLanguageServerBootApp.java index e391a27bfe..5aae0e5585 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootLanguageServerBootApp.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootLanguageServerBootApp.java @@ -72,6 +72,7 @@ import org.springframework.ide.vscode.boot.java.reconcilers.JavaReconciler; import org.springframework.ide.vscode.boot.java.reconcilers.JdtAstReconciler; import org.springframework.ide.vscode.boot.java.reconcilers.JdtReconciler; +import org.springframework.ide.vscode.boot.java.spel.SpelDefinitionProvider; import org.springframework.ide.vscode.boot.java.utils.CompilationUnitCache; import org.springframework.ide.vscode.boot.java.value.ValueDefinitionProvider; import org.springframework.ide.vscode.boot.jdt.ls.JavaProjectsService; @@ -407,7 +408,8 @@ JavaDefinitionHandler javaDefinitionHandler(SimpleLanguageServer server, Compila new ResourceDefinitionProvider(springIndex), new QualifierDefinitionProvider(springIndex), new NamedDefinitionProvider(springIndex), - new DataQueryParameterDefinitionProvider(server.getTextDocumentService(), qurySemanticTokens))); + new DataQueryParameterDefinitionProvider(server.getTextDocumentService(), qurySemanticTokens), + new SpelDefinitionProvider(springIndex, cuCache))); } @Bean diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/jpa/queries/DataQueryParameterDefinitionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/jpa/queries/DataQueryParameterDefinitionProvider.java index a14ccd19f4..c0f9d05009 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/jpa/queries/DataQueryParameterDefinitionProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/jpa/queries/DataQueryParameterDefinitionProvider.java @@ -53,7 +53,7 @@ public List getDefinitions(CancelChecker cancelToken, IJavaProject TextDocument doc = documents.getLatestSnapshot(docId.getUri()); - if (a.getParent() instanceof MethodDeclaration m && !m.parameters().isEmpty()) { + if (a != null && a.getParent() instanceof MethodDeclaration m && !m.parameters().isEmpty()) { Collector collector = new Collector<>(); a.accept(semanticTokensProvider.getTokensComputer(project, doc, cu, collector)); for (SemanticTokenData t : collector.get()) { diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/spel/SpelDefinitionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/spel/SpelDefinitionProvider.java new file mode 100644 index 0000000000..33394885e8 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/spel/SpelDefinitionProvider.java @@ -0,0 +1,317 @@ +/******************************************************************************* + * 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.spel; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.ConsoleErrorListener; +import org.antlr.v4.runtime.Token; +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.ASTVisitor; +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.MethodDeclaration; +import org.eclipse.jdt.core.dom.NormalAnnotation; +import org.eclipse.jdt.core.dom.SimpleName; +import org.eclipse.jdt.core.dom.SingleMemberAnnotation; +import org.eclipse.jdt.core.dom.StringLiteral; +import org.eclipse.lsp4j.LocationLink; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.jsonrpc.CancelChecker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.expression.ParseException; +import org.springframework.expression.spel.SpelNode; +import org.springframework.expression.spel.ast.BeanReference; +import org.springframework.expression.spel.ast.CompoundExpression; +import org.springframework.expression.spel.ast.MethodReference; +import org.springframework.expression.spel.ast.PropertyOrFieldReference; +import org.springframework.expression.spel.ast.TypeReference; +import org.springframework.expression.spel.standard.SpelExpression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.ide.vscode.boot.index.SpringMetamodelIndex; +import org.springframework.ide.vscode.boot.java.Annotations; +import org.springframework.ide.vscode.boot.java.IJavaDefinitionProvider; +import org.springframework.ide.vscode.boot.java.links.SourceLinks; +import org.springframework.ide.vscode.boot.java.spel.AnnotationParamSpelExtractor.Snippet; +import org.springframework.ide.vscode.boot.java.utils.ASTUtils; +import org.springframework.ide.vscode.boot.java.utils.CompilationUnitCache; +import org.springframework.ide.vscode.commons.java.IJavaProject; +import org.springframework.ide.vscode.commons.protocol.spring.Bean; +import org.springframework.ide.vscode.commons.util.BadLocationException; +import org.springframework.ide.vscode.commons.util.text.DocumentRegion; +import org.springframework.ide.vscode.commons.util.text.LanguageId; +import org.springframework.ide.vscode.commons.util.text.TextDocument; +import org.springframework.ide.vscode.parser.spel.SpelLexer; +import org.springframework.ide.vscode.parser.spel.SpelParser; +import org.springframework.ide.vscode.parser.spel.SpelParser.BeanReferenceContext; +import org.springframework.ide.vscode.parser.spel.SpelParserBaseListener; + +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; + +/** + * @author Udayani V + */ +public class SpelDefinitionProvider implements IJavaDefinitionProvider { + + protected static Logger logger = LoggerFactory.getLogger(SpelDefinitionProvider.class); + + private final SpringMetamodelIndex springIndex; + + private final CompilationUnitCache cuCache; + + private final AnnotationParamSpelExtractor[] spelExtractors = AnnotationParamSpelExtractor.SPEL_EXTRACTORS; + + public record TokenData(String text, int start, int end) {}; + + public SpelDefinitionProvider(SpringMetamodelIndex springIndex, CompilationUnitCache cuCache) { + this.springIndex = springIndex; + this.cuCache = cuCache; + } + + @Override + public List getDefinitions(CancelChecker cancelToken, IJavaProject project, + TextDocumentIdentifier docId, CompilationUnit cu, ASTNode n, int offset) { + if (n instanceof StringLiteral) { + StringLiteral valueNode = (StringLiteral) n; + ASTNode parent = ASTUtils.getNearestAnnotationParent(valueNode); + if (parent != null && parent instanceof Annotation) { + + Annotation a = (Annotation) parent; + IAnnotationBinding binding = a.resolveAnnotationBinding(); + if (binding != null && binding.getAnnotationType() != null + && Annotations.VALUE.equals(binding.getAnnotationType().getQualifiedName())) { + return getLocationLinks(project, offset, a); + } + } + } + return Collections.emptyList(); + } + + private List getLocationLinks(IJavaProject project, int offset, Annotation a) { + List locationLink = new ArrayList<>(); + Arrays.stream(spelExtractors).map(e -> { + if (a instanceof SingleMemberAnnotation) + return e.getSpelRegion((SingleMemberAnnotation) a); + else if (a instanceof NormalAnnotation) + return e.getSpelRegion((NormalAnnotation) a); + return Optional.empty(); + }).filter(o -> o.isPresent()).map(o -> o.get()) + .filter(snippet -> { + int tokenEndIndex = snippet.offset() + snippet.text().length(); + return snippet.offset() <= (offset) && (offset) <= tokenEndIndex; + }).forEach(snippet -> { + List beanReferenceTokens = computeTokens(snippet, offset); + if (beanReferenceTokens != null && beanReferenceTokens.size() > 0) { + locationLink.addAll(findLocationLinksForBeanRef(project, offset, beanReferenceTokens)); + } + + Optional> result = parseAndExtractMethodClassPairFromSpel(snippet, offset); + result.ifPresent(tuple -> { + locationLink.addAll(findLocationLinksForMethodRef(tuple.getT1(), tuple.getT2(), project)); + }); + }); + return locationLink; + } + + private List findLocationLinksForBeanRef(IJavaProject project, int offset, + List beanReferenceTokens) { + return beanReferenceTokens.stream().flatMap(t -> findBeansWithName(project, t.text()).stream()) + .collect(Collectors.toList()); + } + + private List findLocationLinksForMethodRef(String methodName, String className, + IJavaProject project) { + URI docUri = null; + try { + if (className.startsWith("T")) { + String classFqName = className.substring(2, className.length() - 1); + Optional sourceUrl = SourceLinks.source(project, classFqName); + if (sourceUrl.isPresent()) { + docUri = sourceUrl.get().toURI(); + } + } else if (className.startsWith("@")) { + String bean = className.substring(1); + List beanLoc = findBeansWithName(project, bean); + if (beanLoc != null && beanLoc.size() > 0) { + docUri = new URI(beanLoc.get(0).getTargetUri()); + } + } + + if (docUri != null) { + return findMethodPositionInDoc(docUri, methodName, project); + } + } catch (Exception e) { + logger.error("", e); + } + return Collections.emptyList(); + } + + private List findMethodPositionInDoc(URI docUrl, String methodName, IJavaProject project) { + + return cuCache.withCompilationUnit(project, docUrl, cu -> { + List locationLinks = new ArrayList<>(); + try { + if (cu != null) { + TextDocument document = new TextDocument(docUrl.toString(), LanguageId.JAVA); + document.setText(cuCache.fetchContent(docUrl)); + cu.accept(new ASTVisitor() { + + @Override + public boolean visit(MethodDeclaration node) { + SimpleName nameNode = node.getName(); + if (nameNode.getIdentifier().equals(methodName)) { + int start = nameNode.getStartPosition(); + int end = start + nameNode.getLength(); + DocumentRegion region = new DocumentRegion(document, start, end); + try { + Range docRange = document.toRange(region); + locationLinks.add(new LocationLink(document.getUri(), docRange, docRange)); + } catch (BadLocationException e) { + logger.error("", e); + } + } + return super.visit(node); + } + }); + } + } catch (URISyntaxException e) { + logger.error("Error parsing the document url: " + docUrl); + } catch (Exception e) { + logger.error("error finding method location in doc '", e); + } + return locationLinks; + }); + } + + private List findBeansWithName(IJavaProject project, String beanName) { + Bean[] beans = this.springIndex.getBeansWithName(project.getElementName(), beanName); + return Arrays.stream(beans).map(bean -> { + return new LocationLink(bean.getLocation().getUri(), bean.getLocation().getRange(), + bean.getLocation().getRange()); + }).collect(Collectors.toList()); + } + + private List computeTokens(Snippet snippet, int offset) { + SpelLexer lexer = new SpelLexer(CharStreams.fromString(snippet.text())); + CommonTokenStream antlrTokens = new CommonTokenStream(lexer); + SpelParser parser = new SpelParser(antlrTokens); + + List beanReferenceTokens = new ArrayList<>(); + + lexer.removeErrorListener(ConsoleErrorListener.INSTANCE); + parser.removeErrorListener(ConsoleErrorListener.INSTANCE); + + parser.addParseListener(new SpelParserBaseListener() { + + @Override + public void exitBeanReference(BeanReferenceContext ctx) { + if (ctx.IDENTIFIER() != null) { + addTokenData(ctx.IDENTIFIER().getSymbol(), offset); + } + if (ctx.STRING_LITERAL() != null) { + addTokenData(ctx.STRING_LITERAL().getSymbol(), offset); + } + } + + private void addTokenData(Token sym, int offset) { + int start = sym.getStartIndex() + snippet.offset(); + int end = sym.getStartIndex() + sym.getText().length() + snippet.offset(); + if (isOffsetWithinToken(start, end, offset)) { + beanReferenceTokens.add(new TokenData(sym.getText(), start, end)); + } + } + + private boolean isOffsetWithinToken(int tokenStartIndex, int tokenEndIndex, int offset) { + return tokenStartIndex <= (offset) && (offset) <= tokenEndIndex; + } + + }); + + parser.spelExpr(); + + return beanReferenceTokens; + } + + private Optional> parseAndExtractMethodClassPairFromSpel(Snippet snippet, int offset) { + SpelExpressionParser parser = new SpelExpressionParser(); + try { + org.springframework.expression.Expression expression = parser.parseExpression(snippet.text()); + + SpelExpression spelExpressionAST = (SpelExpression) expression; + SpelNode rootNode = spelExpressionAST.getAST(); + return extractMethodClassPairFromSpelNodes(rootNode, null, snippet, offset); + } catch (ParseException e) { + logger.error("", e); + } + return Optional.empty(); + } + + private Optional> extractMethodClassPairFromSpelNodes(SpelNode node, SpelNode parent, + Snippet snippet, int offset) { + if (node instanceof MethodReference && checkOffsetInMethodName(node, snippet.offset(), offset)) { + MethodReference methodRef = (MethodReference) node; + String methodName = methodRef.getName(); + String className = extractClassNameFromParent(parent); + if (className != null) { + return Optional.of(Tuples.of(methodName, className)); + } + } + + for (int i = 0; i < node.getChildCount(); i++) { + Optional> result = extractMethodClassPairFromSpelNodes(node.getChild(i), node, + snippet, offset); + if (result.isPresent()) { + return result; + } + } + return Optional.empty(); + } + + private String extractClassNameFromParent(SpelNode parent) { + if (parent != null) { + if (parent instanceof PropertyOrFieldReference) { + return ((PropertyOrFieldReference) parent).getName(); + } else if (parent instanceof TypeReference) { + return ((TypeReference) parent).toStringAST(); + } else if (parent instanceof CompoundExpression) { + for (int i = 0; i < parent.getChildCount(); i++) { + SpelNode child = parent.getChild(i); + if (child instanceof PropertyOrFieldReference || child instanceof BeanReference + || child instanceof TypeReference) { + return child.toStringAST(); + } + } + } + } + return null; + } + + private boolean checkOffsetInMethodName(SpelNode node, int nodeOffset, int offset) { + int start = node.getStartPosition() + nodeOffset; + int end = node.getEndPosition() + nodeOffset; + return start <= (offset) && (offset) <= end; + } + +} diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/index/test/SpringIndexViaLSPMethodTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/index/test/SpringIndexViaLSPMethodTest.java index cb18bfb7f9..70d989b21d 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/index/test/SpringIndexViaLSPMethodTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/index/test/SpringIndexViaLSPMethodTest.java @@ -84,7 +84,7 @@ void testBeansNameAndTypeFromBeanAnnotatedMethod() throws Exception { List beans = result.get(5, TimeUnit.SECONDS); assertNotNull(beans); - assertEquals(17, beans.size()); + assertEquals(19, beans.size()); } @Test @@ -98,7 +98,7 @@ void testMatchingBeansForObject() throws Exception { List beans = result.get(5, TimeUnit.SECONDS); assertNotNull(beans); - assertEquals(16, beans.size()); + assertEquals(18, beans.size()); } @Test diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/index/test/SpringMetamodelIndexingTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/index/test/SpringMetamodelIndexingTest.java index e591e34221..4e06ff4ea4 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/index/test/SpringMetamodelIndexingTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/index/test/SpringMetamodelIndexingTest.java @@ -75,7 +75,7 @@ void testUpdateNotificationAfterProjectCreation() { @Test void testDeleteProject() throws Exception { Bean[] beans = springIndex.getBeansOfProject("test-spring-indexing"); - assertEquals(17, beans.length); + assertEquals(19, beans.length); CompletableFuture deleteProject = indexer.deleteProject(project); deleteProject.get(5, TimeUnit.SECONDS); @@ -92,7 +92,7 @@ void testRemoveSymbolsFromDeletedDocument() throws Exception { String deletedDocURI = directory.toPath().resolve("src/main/java/org/test/injections/ConstructorInjectionService.java").toUri().toString(); Bean[] allBeansOfProject = springIndex.getBeansOfProject("test-spring-indexing"); - assertEquals(17, allBeansOfProject.length); + assertEquals(19, allBeansOfProject.length); Bean[] beans = springIndex.getBeansOfDocument(deletedDocURI); assertEquals(1, beans.length); diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/spel/SpelDefinitionProviderTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/spel/SpelDefinitionProviderTest.java new file mode 100644 index 0000000000..1b8bbcfac2 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/spel/SpelDefinitionProviderTest.java @@ -0,0 +1,248 @@ +/******************************************************************************* + * 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.spel; + +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.Location; +import org.eclipse.lsp4j.LocationLink; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextDocumentIdentifier; +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; + +@ExtendWith(SpringExtension.class) +@BootLanguageServerTest +@Import(SymbolProviderTestConf.class) +public class SpelDefinitionProviderTest { + + @Autowired + private BootLanguageServerHarness harness; + @Autowired + private JavaProjectFinder projectFinder; + @Autowired + private SpringMetamodelIndex springIndex; + @Autowired + private SpringSymbolIndex indexer; + + private File directory; + private IJavaProject project; + + private Bean visitService; + private Bean spelExpressionsClass; + private String expectedDefinitionUriVisitService; + private String expectedDefinitionUriSpelClass; + + @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(); + + expectedDefinitionUriVisitService = directory.toPath().resolve("src/main/java/org/test/VisitService.java").toUri().toString(); + expectedDefinitionUriSpelClass = directory.toPath().resolve("src/main/java/org/test/SpelExpressionsClass.java").toUri().toString(); + visitService = new Bean("visitService", "org.test.VisitService", + new Location(expectedDefinitionUriVisitService, new Range(new Position(4, 0), new Position(4, 8))), + null, null, null); + spelExpressionsClass = new Bean("spelExpressionsClass", "org.test.SpelExpressionsClass", + new Location(expectedDefinitionUriSpelClass, new Range(new Position(7, 0), new Position(7, 11))), null, + null, null); + + springIndex.updateBeans(project.getElementName(), new Bean[] { visitService, spelExpressionsClass }); + + CompletableFuture initProject = indexer.waitOperation(); + initProject.get(5, TimeUnit.SECONDS); + + } + + @Test + public void testBeanDefinitionLinkInSpel() throws Exception { + String tempJavaDocUri = directory.toPath().resolve("src/main/java/org/test/TempClass.java").toUri().toString(); + + Editor editor = harness.newEditor(LanguageId.JAVA, """ + package org.test; + + import org.springframework.beans.factory.annotation.Value; + import org.springframework.stereotype.Controller; + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.ResponseBody; + + @Controller + public class SpelExpressionsClass { + + @Value("${app.version}") + private String appVersion; + + @Value(value = "#{@visitService.isValidVersion('${app.version}') ? 'Valid Version' :'Invalid Version'}") + private String versionValidity; + }""", tempJavaDocUri); + + Bean[] beans = springIndex.getBeansWithName(project.getElementName(), "visitService"); + assertEquals(1, beans.length); + + LocationLink expectedLocation = new LocationLink(expectedDefinitionUriVisitService, + beans[0].getLocation().getRange(), beans[0].getLocation().getRange(), null); + + editor.assertLinkTargets("visitService", List.of(expectedLocation)); + } + + @Test + public void testMultipleBeanDefinitionLinksInSpel() throws Exception { + + String tempJavaDocUri = directory.toPath().resolve("src/main/java/org/test/TempClass.java").toUri().toString(); + + Editor editor = harness.newEditor(LanguageId.JAVA, + """ + package org.test; + + import org.springframework.beans.factory.annotation.Value; + import org.springframework.stereotype.Controller; + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.ResponseBody; + + @Controller + public class SpelExpressionsClass { + + @Value("${app.version}") + private String appVersion; + + @Value("#{@visitService.isValidVersion('${app.version}') ? @spelExpressionsClass.toUpperCase('valid') :@spelExpressionsClass.text2('invalid version')}") + private String fetchVersion; + }""", + tempJavaDocUri); + + Bean[] visitServiceBean = springIndex.getBeansWithName(project.getElementName(), "visitService"); + assertEquals(1, visitServiceBean.length); + + Bean[] spelExpBean = springIndex.getBeansWithName(project.getElementName(), "spelExpressionsClass"); + assertEquals(1, spelExpBean.length); + + LocationLink expectedLocation1 = new LocationLink(expectedDefinitionUriVisitService, + visitServiceBean[0].getLocation().getRange(), visitServiceBean[0].getLocation().getRange(), null); + + LocationLink expectedLocation2 = new LocationLink(expectedDefinitionUriSpelClass, + spelExpBean[0].getLocation().getRange(), spelExpBean[0].getLocation().getRange(), null); + + editor.assertLinkTargets("visitService", List.of(expectedLocation1)); + editor.assertLinkTargets("spelExpressionsClass", List.of(expectedLocation2)); + + } + + @Test + public void testMultipleBeanDefinitionLinksWithTypeRefInSpel() throws Exception { + + String tempJavaDocUri = directory.toPath().resolve("src/main/java/org/test/TempClass.java").toUri().toString(); + + Editor editor = harness.newEditor(LanguageId.JAVA, + """ + package org.test; + + import org.springframework.beans.factory.annotation.Value; + import org.springframework.stereotype.Controller; + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.ResponseBody; + + @Controller + public class SpelExpressionsClass { + + @Value("#{T(org.test.SpelExpressionClass).toUpperCase('hello') + ' ' + @spelExpressionsClass.concat('world', '!')}") + private String greeting; + }""", + tempJavaDocUri); + + Bean[] spelExpBean = springIndex.getBeansWithName(project.getElementName(), "spelExpressionsClass"); + assertEquals(1, spelExpBean.length); + + LocationLink expectedLocation2 = new LocationLink(expectedDefinitionUriSpelClass, + spelExpBean[0].getLocation().getRange(), spelExpBean[0].getLocation().getRange(), null); + + editor.assertLinkTargets("spelExpressionsClass", List.of(expectedLocation2)); + + } + + @Test + public void testMethodDefinitionLinkInSpel() throws Exception { + String tempJavaDocUri = directory.toPath().resolve("src/main/java/org/test/TempClass.java").toUri().toString(); + + Editor editor = harness.newEditor(LanguageId.JAVA, """ + package org.test; + + import org.springframework.beans.factory.annotation.Value; + import org.springframework.stereotype.Controller; + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.ResponseBody; + + @Controller + public class SpelExpressionsClass { + + @Value("${app.version}") + private String appVersion; + + @Value(value = "#{@visitService.isValidVersion('${app.version}') ? 'Valid Version' :'Invalid Version'}") + private String versionValidity; + + @Value("#{T(org.test.SpelExpressionClass).toUpperCase('hello') + ' ' + @spelExpressionsClass.concat('world', '!')}") + private String greeting; + + public static boolean isValidVersion(String version) { + if (version.matches("\\d+\\.\\d+\\.\\d+")) { + String[] parts = version.split("\\."); + int major = Integer.parseInt(parts[0]); + int minor = Integer.parseInt(parts[1]); + int patch = Integer.parseInt(parts[2]); + return (major > 3) || (major == 3 && (minor > 0 || (minor == 0 && patch >= 0))); + } + return false; + } + + public static String toUpperCase(String input) { + return input.toUpperCase(); + } + + public static String concat(String str1, String str2) { + return str1 + str2; + } + }""", tempJavaDocUri); + + LocationLink expectedLocation1 = new LocationLink(expectedDefinitionUriVisitService, + new Range(new Position(7, 23), new Position(7, 37)), new Range(new Position(7, 23), new Position(7, 37)), null); + LocationLink expectedLocation2 = new LocationLink(expectedDefinitionUriSpelClass, + new Range(new Position(37, 22), new Position(37, 28)), new Range(new Position(37, 22), new Position(37, 28)), null); + + editor.assertLinkTargets("isValidVersion", List.of(expectedLocation1)); + editor.assertLinkTargets("concat", List.of(expectedLocation2)); + } + +} diff --git a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-indexing/src/main/java/org/test/SpelExpressionsClass.java b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-indexing/src/main/java/org/test/SpelExpressionsClass.java new file mode 100644 index 0000000000..9aebd544ab --- /dev/null +++ b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-indexing/src/main/java/org/test/SpelExpressionsClass.java @@ -0,0 +1,42 @@ +package org.test; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +@Controller +public class SpelExpressionsClass { + + @Value("${app.version}") + private String appVersion; + + @Value("#{@visitService.isValidVersion('${app.version}') ? 'Valid Version' :'Invalid Version'}") + private String versionValidity; + + @Value("value = #{@visitService.isValidVersion('${app.version}') ? @spelExpressionClass.toUpperCase('valid') :@spelExpressionClass.text2('invalid version')}") + private String fetchVersion; + + @Value("#{T(org.test.SpelExpressionClass).toUpperCase('hello') + ' ' + @spelExpressionsClass.concat('world', '!')}") + private String greeting; + + public static boolean isValidVersion(String version) { + if (version.matches("\\d+\\.\\d+\\.\\d+")) { + String[] parts = version.split("\\."); + int major = Integer.parseInt(parts[0]); + int minor = Integer.parseInt(parts[1]); + int patch = Integer.parseInt(parts[2]); + return (major > 3) || (major == 3 && (minor > 0 || (minor == 0 && patch >= 0))); + } + return false; + } + + public static String toUpperCase(String input) { + return input.toUpperCase(); + } + + public static String concat(String str1, String str2) { + return str1 + str2; + } + +} diff --git a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-indexing/src/main/java/org/test/VisitService.java b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-indexing/src/main/java/org/test/VisitService.java new file mode 100644 index 0000000000..babfb2438b --- /dev/null +++ b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-indexing/src/main/java/org/test/VisitService.java @@ -0,0 +1,19 @@ +package org.test; + +import org.springframework.stereotype.Service; + +@Service +public class VisitService { + + public static boolean isValidVersion(String version) { + if (version.matches("\\d+\\.\\d+\\.\\d+")) { + String[] parts = version.split("\\."); + int major = Integer.parseInt(parts[0]); + int minor = Integer.parseInt(parts[1]); + int patch = Integer.parseInt(parts[2]); + return (major > 3) || (major == 3 && (minor > 0 || (minor == 0 && patch >= 0))); + } + return false; + } + +} diff --git a/vscode-extensions/vscode-spring-boot/package.json b/vscode-extensions/vscode-spring-boot/package.json index 34e35d1369..64e3472cd9 100644 --- a/vscode-extensions/vscode-spring-boot/package.json +++ b/vscode-extensions/vscode-spring-boot/package.json @@ -53,7 +53,7 @@ "./jars/sts-gradle-tooling.jar" ], "languages": [ - { + { "id": "spring-boot-properties-yaml", "aliases": [ "Spring Boot Properties Yaml" @@ -1318,28 +1318,28 @@ "path": "./properties-support/spring-factories.tmLanguage.json" } ], - "configurationDefaults": { - "[spring-boot-properties-yaml]": { - "editor.quickSuggestions": { - "strings": true - } - }, - "[spring-boot-properties]": { - "editor.quickSuggestions": { - "strings": true - } - }, - "[jpa-query-properties]": { - "editor.quickSuggestions": { - "strings": true - } - }, - "[spring-factories]": { - "editor.quickSuggestions": { - "strings": true - } - } - } + "configurationDefaults": { + "[spring-boot-properties-yaml]": { + "editor.quickSuggestions": { + "strings": true + } + }, + "[spring-boot-properties]": { + "editor.quickSuggestions": { + "strings": true + } + }, + "[jpa-query-properties]": { + "editor.quickSuggestions": { + "strings": true + } + }, + "[spring-factories]": { + "editor.quickSuggestions": { + "strings": true + } + } + } }, "main": "./out/lib/Main", "scripts": { @@ -1367,4 +1367,4 @@ "extensionDependencies": [ "redhat.java" ] -} \ No newline at end of file +}