GH-1285: add support for code completion for classpath resources in value annotation

This commit is contained in:
Martin Lippert
2024-07-03 12:20:23 +02:00
parent ab23402b34
commit 32671aeb92
5 changed files with 318 additions and 37 deletions

View File

@@ -1,5 +1,5 @@
/*******************************************************************************
* Copyright (c) 2017, 2019 Pivotal, Inc.
* Copyright (c) 2017, 2024 Pivotal, 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
@@ -22,13 +22,18 @@ import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.StringLiteral;
import org.eclipse.lsp4j.TextDocumentIdentifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ide.vscode.boot.java.beans.QualifierCompletionProposal;
import org.springframework.ide.vscode.boot.java.handlers.CompletionProvider;
import org.springframework.ide.vscode.boot.metadata.ProjectBasedPropertyIndexProvider;
import org.springframework.ide.vscode.boot.metadata.PropertyInfo;
import org.springframework.ide.vscode.boot.metadata.SpringPropertyIndexProvider;
import org.springframework.ide.vscode.commons.java.IClasspathUtil;
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;
@@ -43,6 +48,8 @@ import org.springframework.ide.vscode.commons.util.text.TextDocument;
* @author Martin Lippert
*/
public class ValueCompletionProcessor implements CompletionProvider {
private static final Logger log = LoggerFactory.getLogger(ValueCompletionProcessor.class);
private final SpringPropertyIndexProvider indexProvider;
private final ProjectBasedPropertyIndexProvider adHocIndexProvider;
@@ -59,6 +66,13 @@ public class ValueCompletionProcessor implements CompletionProvider {
int offset, TextDocument doc, Collection<ICompletionProposal> completions) {
try {
Optional<IJavaProject> optionalProject = this.projectFinder.find(doc.getId());
if (optionalProject.isEmpty()) {
return;
}
IJavaProject project = optionalProject.get();
// case: @Value(<*>)
if (node == annotation && doc.get(offset - 1, 2).endsWith("()")) {
List<Match<PropertyInfo>> matches = findMatches("", doc);
@@ -75,37 +89,70 @@ public class ValueCompletionProcessor implements CompletionProvider {
completions.add(proposal);
}
addClasspathResourceProposals(project, doc, offset, offset, "", true, completions);
}
// case: @Value(prefix<*>)
else if (node instanceof SimpleName && node.getParent() instanceof Annotation) {
computeProposalsForSimpleName(node, completions, offset, doc);
computeProposalsForSimpleName(project, node, completions, offset, doc);
}
// case: @Value(file.ext<*>) - the "." causes a QualifierNode to be generated
else if (node instanceof SimpleName && node.getParent() instanceof QualifiedName && node.getParent().getParent() instanceof Annotation) {
computeProposalsForSimpleName(project, node.getParent(), completions, offset, doc);
}
// case: @Value(value=<*>)
else if (node instanceof SimpleName && node.getParent() instanceof MemberValuePair
&& "value".equals(((MemberValuePair)node.getParent()).getName().toString())) {
computeProposalsForSimpleName(node, completions, offset, doc);
computeProposalsForSimpleName(project, node, completions, offset, doc);
}
// case: @Value(value=<*>)
else if (node instanceof SimpleName && node.getParent() instanceof QualifiedName && node.getParent().getParent() instanceof MemberValuePair
&& "value".equals(((MemberValuePair)node.getParent().getParent()).getName().toString())) {
computeProposalsForSimpleName(project, node.getParent(), completions, offset, doc);
}
// case: @Value("prefix<*>")
else if (node instanceof StringLiteral && node.getParent() instanceof Annotation) {
if (node.toString().startsWith("\"") && node.toString().endsWith("\"")) {
computeProposalsForStringLiteral(node, completions, offset, doc);
computeProposalsForStringLiteral(project, (StringLiteral) node, completions, offset, doc);
}
}
// case: @Value(value="prefix<*>")
else if (node instanceof StringLiteral && node.getParent() instanceof MemberValuePair
&& "value".equals(((MemberValuePair)node.getParent()).getName().toString())) {
if (node.toString().startsWith("\"") && node.toString().endsWith("\"")) {
computeProposalsForStringLiteral(node, completions, offset, doc);
computeProposalsForStringLiteral(project, (StringLiteral) node, completions, offset, doc);
}
}
}
catch (Exception e) {
e.printStackTrace();
log.error("problem while looking for value annotation proposals", e);
}
}
private void computeProposalsForSimpleName(ASTNode node, Collection<ICompletionProposal> completions, int offset,
IDocument doc) {
private void addClasspathResourceProposals(IJavaProject project, TextDocument doc, int startOffset, int endOffset, String prefix, boolean includeQuotes, Collection<ICompletionProposal> completions) {
String[] resources = findResources(project, prefix);
double score = resources.length + 1000;
for (String resource : resources) {
DocumentEdits edits = new DocumentEdits(doc, false);
if (includeQuotes) {
edits.replace(startOffset, endOffset, "\"classpath:" + resource + "\"");
}
else {
edits.replace(startOffset, endOffset, "classpath:" + resource);
}
String label = "classpath:" + resource;
ICompletionProposal proposal = new QualifierCompletionProposal(edits, label, label, null, score--);
completions.add(proposal);
}
}
private void computeProposalsForSimpleName(IJavaProject project, ASTNode node, Collection<ICompletionProposal> completions, int offset, TextDocument doc) {
String prefix = identifyPropertyPrefix(node.toString(), offset - node.getStartPosition());
int startOffset = node.getStartPosition();
@@ -125,10 +172,12 @@ public class ValueCompletionProcessor implements CompletionProvider {
completions.add(proposal);
}
String unfilteredPrefix = node.toString().substring(0, offset - node.getStartPosition());
addClasspathResourceProposals(project, doc, startOffset, endOffset, unfilteredPrefix, true, completions);
}
private void computeProposalsForStringLiteral(ASTNode node, Collection<ICompletionProposal> completions, int offset,
IDocument doc) throws BadLocationException {
private void computeProposalsForStringLiteral(IJavaProject project, StringLiteral node, Collection<ICompletionProposal> completions, int offset, TextDocument doc) throws BadLocationException {
String prefix = identifyPropertyPrefix(doc.get(node.getStartPosition() + 1, offset - (node.getStartPosition() + 1)), offset - (node.getStartPosition() + 1));
int startOffset = offset - prefix.length();
@@ -164,6 +213,9 @@ public class ValueCompletionProcessor implements CompletionProvider {
completions.add(proposal);
}
String unfilteredPrefix = node.getLiteralValue().substring(0, offset - (node.getStartPosition() + 1));
addClasspathResourceProposals(project, doc, startOffset, endOffset, unfilteredPrefix, false, completions);
}
private boolean isClosingBracketMissing(String fullNodeContent) {
@@ -220,4 +272,14 @@ public class ValueCompletionProcessor implements CompletionProvider {
return matches;
}
private String[] findResources(IJavaProject project, String prefix) {
String[] resources = IClasspathUtil.getClasspathResources(project.getClasspath()).stream()
.distinct()
.map(r -> r.replaceAll("\\\\", "/"))
.filter(r -> ("classpath:" + r).contains(prefix))
.toArray(String[]::new);
return resources;
}
}

View File

@@ -153,10 +153,15 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(<*>)");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"${data.prop2}\"<*>)",
"@Value(\"${else.prop3}\"<*>)",
"@Value(\"${spring.prop1}\"<*>)");
assertClasspathCompletions(
"@Value(\"classpath:random-resource-root.md\"<*>)",
"@Value(\"classpath:org/random-resource-org.md\"<*>)",
"@Value(\"classpath:org/test/random-resource-org-test.txt\"<*>)");
}
@Test
@@ -164,17 +169,23 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(value=<*>)");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(value=\"${data.prop2}\"<*>)",
"@Value(value=\"${else.prop3}\"<*>)",
"@Value(value=\"${spring.prop1}\"<*>)");
assertClasspathCompletions(
"@Value(value=\"classpath:random-resource-root.md\"<*>)",
"@Value(value=\"classpath:org/random-resource-org.md\"<*>)",
"@Value(value=\"classpath:org/test/random-resource-org-test.txt\"<*>)");
}
@Test
void testEmptyBracketsCompletionWithWrongParamName() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(another=<*>)");
prepareDefaultIndexData();
assertAnnotationCompletions();
assertPropertyCompletions();
assertClasspathCompletions();
}
@Test
@@ -182,10 +193,12 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value($<*>)");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"${data.prop2}\"<*>)",
"@Value(\"${else.prop3}\"<*>)",
"@Value(\"${spring.prop1}\"<*>)");
assertClasspathCompletions();
}
@Test
@@ -193,10 +206,12 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(value=$<*>)");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(value=\"${data.prop2}\"<*>)",
"@Value(value=\"${else.prop3}\"<*>)",
"@Value(value=\"${spring.prop1}\"<*>)");
assertClasspathCompletions();
}
@Test
@@ -204,10 +219,12 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(\"$<*>\")");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"${data.prop2}<*>\")",
"@Value(\"${else.prop3}<*>\")",
"@Value(\"${spring.prop1}<*>\")");
assertClasspathCompletions();
}
@Test
@@ -215,10 +232,12 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(value=\"$<*>\")");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(value=\"${data.prop2}<*>\")",
"@Value(value=\"${else.prop3}<*>\")",
"@Value(value=\"${spring.prop1}<*>\")");
assertClasspathCompletions();
}
@Test
@@ -226,10 +245,12 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(\"${<*>}\")");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"${data.prop2<*>}\")",
"@Value(\"${else.prop3<*>}\")",
"@Value(\"${spring.prop1<*>}\")");
assertClasspathCompletions();
}
@Test
@@ -237,10 +258,12 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(value=\"${<*>}\")");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(value=\"${data.prop2<*>}\")",
"@Value(value=\"${else.prop3<*>}\")",
"@Value(value=\"${spring.prop1<*>}\")");
assertClasspathCompletions();
}
@Test
@@ -248,10 +271,15 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(\"<*>\")");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"${data.prop2}<*>\")",
"@Value(\"${else.prop3}<*>\")",
"@Value(\"${spring.prop1}<*>\")");
assertClasspathCompletions(
"@Value(\"classpath:random-resource-root.md<*>\")",
"@Value(\"classpath:org/random-resource-org.md<*>\")",
"@Value(\"classpath:org/test/random-resource-org-test.txt<*>\")");
}
@Test
@@ -259,8 +287,124 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(spri<*>)");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"${spring.prop1}\"<*>)");
assertClasspathCompletions();
}
@Test
void testComplexPrefixCompletion() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(spring.pr<*>)");
prepareDefaultIndexData();
assertPropertyCompletions(
"@Value(\"${spring.prop1}\"<*>)");
assertClasspathCompletions();
}
@Test
void testPrefixCompletionWithParamName() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(value=sprin<*>)");
prepareDefaultIndexData();
assertPropertyCompletions(
"@Value(value=\"${spring.prop1}\"<*>)");
assertClasspathCompletions();
}
@Test
void testComplexPrefixCompletionWithParamName() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(value=spring.pr<*>)");
prepareDefaultIndexData();
assertPropertyCompletions(
"@Value(value=\"${spring.prop1}\"<*>)");
assertClasspathCompletions();
}
@Test
void testClasspathPrefixCompletion() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(cla<*>)");
prepareDefaultIndexData();
assertPropertyCompletions();
assertClasspathCompletions(
"@Value(\"classpath:random-resource-root.md\"<*>)",
"@Value(\"classpath:org/random-resource-org.md\"<*>)",
"@Value(\"classpath:org/test/random-resource-org-test.txt\"<*>)");
}
@Test
void testResourceNameInPrefixCompletion() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(root<*>)");
prepareDefaultIndexData();
assertPropertyCompletions();
assertClasspathCompletions(
"@Value(\"classpath:random-resource-root.md\"<*>)");
}
@Test
void testComplexResourceNameInPrefixCompletion() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(root.md<*>)");
prepareDefaultIndexData();
assertPropertyCompletions();
assertClasspathCompletions(
"@Value(\"classpath:random-resource-root.md\"<*>)");
}
@Test
void testComplexResourceNameInPrefixWithinQoutesCompletion() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(\"root.md<*>\")");
prepareDefaultIndexData();
assertPropertyCompletions();
assertClasspathCompletions(
"@Value(\"classpath:random-resource-root.md<*>\")");
}
@Test
void testComplexResourceNameInPrefixWithParamNameCompletion() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(value=root.md<*>)");
prepareDefaultIndexData();
assertPropertyCompletions();
assertClasspathCompletions(
"@Value(value=\"classpath:random-resource-root.md\"<*>)");
}
@Test
void testComplexResourceNameInPrefixWithinQoutesAndParamNameCompletion() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(value=\"root.md<*>\")");
prepareDefaultIndexData();
assertPropertyCompletions();
assertClasspathCompletions(
"@Value(value=\"classpath:random-resource-root.md<*>\")");
}
@Test
void testClasspathPrefixCompletionWithParamName() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(value=cla<*>)");
prepareDefaultIndexData();
assertPropertyCompletions();
assertClasspathCompletions(
"@Value(value=\"classpath:random-resource-root.md\"<*>)",
"@Value(value=\"classpath:org/random-resource-org.md\"<*>)",
"@Value(value=\"classpath:org/test/random-resource-org-test.txt\"<*>)");
}
@Test
@@ -268,8 +412,34 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(\"spri<*>\")");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"${spring.prop1}<*>\")");
assertClasspathCompletions();
}
@Test
void testComplexPrefixCompletionWithQuotes() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(\"spring.pr<*>\")");
prepareDefaultIndexData();
assertPropertyCompletions(
"@Value(\"${spring.prop1}<*>\")");
assertClasspathCompletions();
}
@Test
void testQuotedClasspathPrefixCompletion() throws Exception {
prepareCase("@Value(\"onField\")", "@Value(\"cla<*>\")");
prepareDefaultIndexData();
assertPropertyCompletions();
assertClasspathCompletions(
"@Value(\"classpath:random-resource-root.md<*>\")",
"@Value(\"classpath:org/random-resource-org.md<*>\")",
"@Value(\"classpath:org/test/random-resource-org-test.txt<*>\")");
}
@Test
@@ -277,10 +447,12 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(\"#{<*>}\")");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"#{${data.prop2}<*>}\")",
"@Value(\"#{${else.prop3}<*>}\")",
"@Value(\"#{${spring.prop1}<*>}\")");
assertClasspathCompletions();
}
@Test
@@ -288,10 +460,12 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(\"#{345$<*>}\")");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"#{345${data.prop2}<*>}\")",
"@Value(\"#{345${else.prop3}<*>}\")",
"@Value(\"#{345${spring.prop1}<*>}\")");
assertClasspathCompletions();
}
@Test
@@ -299,10 +473,12 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(\"#{345${<*>}\")");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"#{345${data.prop2}<*>}\")",
"@Value(\"#{345${else.prop3}<*>}\")",
"@Value(\"#{345${spring.prop1}<*>}\")");
assertClasspathCompletions();
}
@Test
@@ -310,10 +486,12 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(\"#{345${<*>}}\")");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"#{345${data.prop2<*>}}\")",
"@Value(\"#{345${else.prop3<*>}}\")",
"@Value(\"#{345${spring.prop1<*>}}\")");
assertClasspathCompletions();
}
@Test
@@ -321,8 +499,10 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(\"#{345${spri<*>}\")");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"#{345${spring.prop1}<*>}\")");
assertClasspathCompletions();
}
@Test
@@ -330,8 +510,10 @@ public class ValueCompletionTest {
prepareCase("@Value(\"onField\")", "@Value(\"#{345${spri<*>}}\")");
prepareDefaultIndexData();
assertAnnotationCompletions(
assertPropertyCompletions(
"@Value(\"#{345${spring.prop1<*>}}\")");
assertClasspathCompletions();
}
@Test
@@ -353,6 +535,10 @@ public class ValueCompletionTest {
editor.assertContextualCompletions(
"<*>"
, //==>
"classpath:random-resource-root.md<*>",
"classpath:org/random-resource-org.md<*>",
"classpath:org/test/random-resource-org-test.txt<*>",
"${data.prop2}<*>",
"${else.prop3}<*>",
"${spring.prop1}<*>"
@@ -364,7 +550,11 @@ public class ValueCompletionTest {
editor.assertContextualCompletions(
"<*>"
, //==>
"${data.prop2}<*>",
"classpath:random-resource-root.md<*>",
"classpath:org/random-resource-org.md<*>",
"classpath:org/test/random-resource-org-test.txt<*>",
"${data.prop2}<*>",
"${else.prop3}<*>",
"${spring.ad-hoc.other-thingy}<*>",
"${spring.ad-hoc.thingy}<*>",
@@ -394,18 +584,47 @@ public class ValueCompletionTest {
editor = new Editor(harness, content, LanguageId.JAVA);
}
private void assertAnnotationCompletions(String... completedAnnotations) throws Exception {
private void assertPropertyCompletions(String... completedAnnotations) throws Exception {
List<CompletionItem> completions = editor.getCompletions();
int i = 0;
for (String expectedCompleted : completedAnnotations) {
List<CompletionItem> filteredCompletions = completions.stream()
.filter(item -> !item.getTextEdit().getLeft().getNewText().contains("classpath"))
.toList();
assertEquals(completedAnnotations.length, filteredCompletions.size());
for (int i = 0; i < completedAnnotations.length; i++) {
CompletionItem completion = filteredCompletions.get(i);
Editor clonedEditor = editor.clone();
clonedEditor.apply(completions.get(i++));
if (!clonedEditor.getText().contains(expectedCompleted)) {
fail("Not found '"+expectedCompleted+"' in \n"+clonedEditor.getText());
clonedEditor.apply(completion);
String expected = completedAnnotations[i];
if (!clonedEditor.getText().contains(expected)) {
fail("Not found '" + expected +"' in \n" + clonedEditor.getText());
}
}
assertEquals(i, completions.size());
}
private void assertClasspathCompletions(String... completedAnnotations) throws Exception {
List<CompletionItem> completions = editor.getCompletions();
List<CompletionItem> filteredCompletions = completions.stream()
.filter(item -> item.getTextEdit().getLeft().getNewText().contains("classpath"))
.toList();
assertEquals(completedAnnotations.length, filteredCompletions.size());
for (int i = 0; i < completedAnnotations.length; i++) {
CompletionItem completion = filteredCompletions.get(i);
Editor clonedEditor = editor.clone();
clonedEditor.apply(completion);
String expected = completedAnnotations[i];
if (!clonedEditor.getText().contains(expected)) {
fail("Not found '" + expected +"' in \n" + clonedEditor.getText());
}
}
}
}