Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GH-1323, GH-1324: Cron Expressions completion proposals and inlay hints #1357

Merged
merged 2 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@
import org.eclipse.lsp4j.HoverParams;
import org.eclipse.lsp4j.InitializeParams;
import org.eclipse.lsp4j.InitializeResult;
import org.eclipse.lsp4j.InlayHint;
import org.eclipse.lsp4j.InlayHintParams;
import org.eclipse.lsp4j.Location;
import org.eclipse.lsp4j.LocationLink;
import org.eclipse.lsp4j.MarkupContent;
Expand Down Expand Up @@ -681,6 +683,12 @@ public List<? extends CodeLens> getCodeLenses(TextDocumentInfo document) throws
params.setTextDocument(document.getId());
return getServer().getTextDocumentService().codeLens(params).get();
}

public List<InlayHint> getInlayHints(TextDocumentInfo document) throws Exception {
InlayHintParams params = new InlayHintParams();
params.setTextDocument(document.getId());
return getServer().getTextDocumentService().inlayHint(params).get();
}

public List<? extends DocumentHighlight> getDocumentHighlights(TextDocumentIdentifier docId, Position cursor) throws InterruptedException, ExecutionException {
return getServer().getTextDocumentService().documentHighlight(new DocumentHighlightParams(docId, cursor)).get();
Expand Down
7 changes: 7 additions & 0 deletions headless-services/spring-boot-language-server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@
<artifactId>commons-util</artifactId>
<version>1.58.0-SNAPSHOT</version>
</dependency>

<!-- Cron expression descriptor library -->
<dependency>
<groupId>com.cronutils</groupId>
<artifactId>cron-utils</artifactId>
<version>9.2.0</version>
</dependency>
</dependencies>

<profiles>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
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.beans.ResourceCompletionProvider;
import org.springframework.ide.vscode.boot.java.conditionalonresource.ConditionalOnResourceCompletionProcessor;
import org.springframework.ide.vscode.boot.java.contextconfiguration.ContextConfigurationProcessor;
import org.springframework.ide.vscode.boot.java.cron.CronExpressionCompletionProvider;
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;
Expand All @@ -40,8 +43,6 @@
import org.springframework.ide.vscode.boot.java.utils.ASTUtils;
import org.springframework.ide.vscode.boot.java.utils.CompilationUnitCache;
import org.springframework.ide.vscode.boot.java.value.ValueCompletionProcessor;
import org.springframework.ide.vscode.boot.java.contextconfiguration.ContextConfigurationProcessor;
import org.springframework.ide.vscode.boot.java.conditionalonresource.ConditionalOnResourceCompletionProcessor;
import org.springframework.ide.vscode.boot.metadata.ProjectBasedPropertyIndexProvider;
import org.springframework.ide.vscode.boot.metadata.SpringPropertyIndexProvider;
import org.springframework.ide.vscode.commons.languageserver.java.JavaProjectFinder;
Expand Down Expand Up @@ -132,6 +133,8 @@ BootJavaCompletionEngine javaCompletionEngine(

providers.put(Annotations.NAMED_JAKARTA, new AnnotationAttributeCompletionProcessor(javaProjectFinder, Map.of("value", new NamedCompletionProvider(springIndex))));
providers.put(Annotations.NAMED_JAVAX, new AnnotationAttributeCompletionProcessor(javaProjectFinder, Map.of("value", new NamedCompletionProvider(springIndex))));

providers.put(Annotations.SCHEDULED, new AnnotationAttributeCompletionProcessor(javaProjectFinder, Map.of("cron", new CronExpressionCompletionProvider())));

return new BootJavaCompletionEngine(cuCache, providers, snippetManager);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.ide.vscode.boot.java.cron.CronExpressionsInlayHintsProvider;
import org.springframework.ide.vscode.boot.java.cron.CronReconciler;
import org.springframework.ide.vscode.boot.java.cron.CronSemanticTokens;
import org.springframework.ide.vscode.boot.java.cron.JdtCronReconciler;
Expand Down Expand Up @@ -136,6 +137,10 @@ public class JdtConfig {
return new JdtDataQueriesInlayHintsProvider(semanticTokensProvider);
}

@Bean CronExpressionsInlayHintsProvider cronExpressionsInlayHintsProvider() {
return new CronExpressionsInlayHintsProvider();
}

@Bean JdtQueryDocHighlightsProvider jdtDocHighlightsProvider(JdtDataQuerySemanticTokensProvider semanticTokensProvider) {
return new JdtQueryDocHighlightsProvider(semanticTokensProvider);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.StringLiteral;
import org.springframework.ide.vscode.boot.java.cron.CronExpressionCompletionProvider;
import org.springframework.ide.vscode.boot.java.handlers.CompletionProvider;
import org.springframework.ide.vscode.commons.java.IJavaProject;
import org.springframework.ide.vscode.commons.languageserver.completion.DocumentEdits;
Expand Down Expand Up @@ -114,34 +115,52 @@ else if (node instanceof ArrayInitializer && node.getParent() instanceof Annotat
/**
* create the concrete completion proposal
*/
private void createCompletionProposals(IJavaProject project, TextDocument doc, ASTNode node, String attributeName, Collection<ICompletionProposal> completions, int startOffset, int endOffset,
String filterPrefix, Function<String, String> createReplacementText) {
private void createCompletionProposals(IJavaProject project, TextDocument doc, ASTNode node, String attributeName,
Collection<ICompletionProposal> completions, int startOffset, int endOffset, String filterPrefix,
Function<String, String> createReplacementText) {

Set<String> alreadyMentionedValues = alreadyMentionedValues(node);

AnnotationAttributeCompletionProvider completionProvider = this.completionProviders.get(attributeName);
if (completionProvider != null) {
List<String> candidates = completionProvider.getCompletionCandidates(project);

List<String> filteredCandidates = candidates.stream()
if (completionProvider instanceof CronExpressionCompletionProvider) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... this bit does not look pretty... Some Cron completions specific bit in the generic AnnotationAttributeCompletionProcessor... Any chance we can push this logic down to the CRON completion provider somehow?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest that we just switch the method AnnotationAttributeCompletionProvider.getCompletionCandidates to return labels for the proposals as well and don't add an additional method for that. The AnnotationAttributeCompletionProcessor would then not need to distinguish between provider with and without labels and assume instead that the labels are coming from the providers.

Then, we need to adapt all the various provider implementations to return labels as well, but that should't be a big deal.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vudayani Do you want to do those changes in the PR directly or do you want me to do those changes in main first, and then you adapt the PR to that?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@martinlippert I can do the changes in this PR if it can wait until tomorrow.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, sounds good. Looking forward to the updated PR then.

Map<String, String> proposals = completionProvider.getCompletionCandidatesWithLabels(project);
Map<String, String> filteredProposals = proposals.entrySet().stream()
.filter(candidate -> candidate.getKey().toLowerCase().contains(filterPrefix.toLowerCase()))
.filter(candidate -> !alreadyMentionedValues.contains(candidate.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
double score = filteredProposals.size();
for (Map.Entry<String, String> entry : filteredProposals.entrySet()) {
String candidate = entry.getKey();
DocumentEdits edits = new DocumentEdits(doc, false);
edits.replace(startOffset, endOffset, createReplacementText.apply(candidate));
AnnotationAttributeCompletionProposal proposal = new AnnotationAttributeCompletionProposal(edits,
candidate, entry.getValue(), null, score--);
completions.add(proposal);

}
} else {

List<String> filteredCandidates = candidates.stream()
// .filter(candidate -> candidate.toLowerCase().startsWith(filterPrefix.toLowerCase()))
.filter(candidate -> candidate.toLowerCase().contains(filterPrefix.toLowerCase()))
.filter(candidate -> !alreadyMentionedValues.contains(candidate))
.collect(Collectors.toList());
.filter(candidate -> candidate.toLowerCase().contains(filterPrefix.toLowerCase()))
.filter(candidate -> !alreadyMentionedValues.contains(candidate)).collect(Collectors.toList());
double score = filteredCandidates.size();
for (String candidate : filteredCandidates) {

double score = filteredCandidates.size();
for (String candidate : filteredCandidates) {

DocumentEdits edits = new DocumentEdits(doc, false);
edits.replace(startOffset, endOffset, createReplacementText.apply(candidate));

AnnotationAttributeCompletionProposal proposal = new AnnotationAttributeCompletionProposal(edits, candidate, candidate, null, score--);
completions.add(proposal);
DocumentEdits edits = new DocumentEdits(doc, false);
edits.replace(startOffset, endOffset, createReplacementText.apply(candidate));

AnnotationAttributeCompletionProposal proposal = new AnnotationAttributeCompletionProposal(edits,
candidate, candidate, null, score--);
completions.add(proposal);
}
}
}
}


//
// internal computation of the right positions, prefixes, etc.
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,22 @@
*******************************************************************************/
package org.springframework.ide.vscode.boot.java.annotations;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.springframework.ide.vscode.commons.java.IJavaProject;

public interface AnnotationAttributeCompletionProvider {

List<String> getCompletionCandidates(IJavaProject project);
default List<String> getCompletionCandidates(IJavaProject project) {
return new ArrayList<>();
}


default Map<String, String> getCompletionCandidatesWithLabels(IJavaProject project) {
return new HashMap<>();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package org.springframework.ide.vscode.boot.java.cron;

import java.util.LinkedHashMap;
import java.util.Map;

import org.springframework.ide.vscode.boot.java.annotations.AnnotationAttributeCompletionProvider;
import org.springframework.ide.vscode.commons.java.IJavaProject;

public class CronExpressionCompletionProvider implements AnnotationAttributeCompletionProvider {

private static final Map<String, String> CRON_EXPRESSIONS_MAP = new LinkedHashMap<>();

static {
CRON_EXPRESSIONS_MAP.put("0 0 * * * 1-5", "every hour every day between Monday and Friday");
CRON_EXPRESSIONS_MAP.put("0 */5 * * * *", "every 5 minutes");
CRON_EXPRESSIONS_MAP.put("0 * * * * *", "every minute");
CRON_EXPRESSIONS_MAP.put("0 0 */6 * * *", "every 6 hours at minute 0");
CRON_EXPRESSIONS_MAP.put("0 0 * * * *", "every hour");
CRON_EXPRESSIONS_MAP.put("0 0 * * * SUN", "every hour at Sunday day");
CRON_EXPRESSIONS_MAP.put("0 0 0 * * *", "at 00:00");
CRON_EXPRESSIONS_MAP.put("0 0 0 * * SAT,SUN", "at 00:00 on Saturday and Sunday");
CRON_EXPRESSIONS_MAP.put("0 0 0 * * 6,0", "at 00:00 at Saturday and Sunday days");
CRON_EXPRESSIONS_MAP.put("0 0 0 1-7 * SUN", "at 00:00 every day between 1 and 7 at Sunday day");
CRON_EXPRESSIONS_MAP.put("0 0 0 1 * *", "at 00:00 at 1 day");
CRON_EXPRESSIONS_MAP.put("0 0 0 1 1 *", "at 00:00 at 1 day at January month");
CRON_EXPRESSIONS_MAP.put("0 0 8-18 * * *", "every hour between 8 and 18");
CRON_EXPRESSIONS_MAP.put("0 0 9 * * MON", "at 09:00 at Monday day");
CRON_EXPRESSIONS_MAP.put("0 0 10 * * *", "at 10:00");
CRON_EXPRESSIONS_MAP.put("0 30 9 * JAN MON", "at 09:30 at January month at Monday day");
CRON_EXPRESSIONS_MAP.put("10 * * * * *", "every minute at second 10");
CRON_EXPRESSIONS_MAP.put("0 0 8-10 * * *", "every hour between 8 and 10");
CRON_EXPRESSIONS_MAP.put("0 0/30 8-10 * * *", "every 30 minutes every hour between 8 and 10");
CRON_EXPRESSIONS_MAP.put("0 0 0 L * *", " at 00:00 last day of month");
CRON_EXPRESSIONS_MAP.put("0 0 0 1W * *", "at 00:00 the nearest weekday to the 1 of the month");
CRON_EXPRESSIONS_MAP.put("0 0 0 * * THUL", "at 00:00 last Thursday of every month");
CRON_EXPRESSIONS_MAP.put("0 0 0 ? * 5#2", "at 00:00 Friday 2 of every month");
CRON_EXPRESSIONS_MAP.put("0 0 0 ? * MON#1", "at 00:00 Monday 1 of every month");
}


@Override
public Map<String, String> getCompletionCandidatesWithLabels(IJavaProject project) {
return CRON_EXPRESSIONS_MAP;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package org.springframework.ide.vscode.boot.java.cron;

import java.util.Locale;

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.Expression;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.StringLiteral;
import org.eclipse.jdt.core.dom.TextBlock;
import org.eclipse.lsp4j.InlayHint;
import org.eclipse.lsp4j.InlayHintKind;
import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ide.vscode.boot.java.Annotations;
import org.springframework.ide.vscode.boot.java.JdtInlayHintsProvider;
import org.springframework.ide.vscode.commons.java.IJavaProject;
import org.springframework.ide.vscode.commons.util.Collector;
import org.springframework.ide.vscode.commons.util.text.TextDocument;
import org.springframework.scheduling.support.CronExpression;

import com.cronutils.descriptor.CronDescriptor;
import com.cronutils.model.definition.CronDefinition;
import com.cronutils.model.definition.CronDefinitionBuilder;
import com.cronutils.parser.CronParser;

import static com.cronutils.model.CronType.SPRING;

public class CronExpressionsInlayHintsProvider implements JdtInlayHintsProvider {

protected static Logger logger = LoggerFactory.getLogger(CronExpressionsInlayHintsProvider.class);

private static final String SCHEDULED = "Scheduled";

public record EmbeddedCronExpression(Expression expression, String text, int offset) {
};

@Override
public boolean isApplicable(IJavaProject project) {
return true;
}

@Override
public ASTVisitor getInlayHintsComputer(IJavaProject project, TextDocument doc, CompilationUnit cu,
Collector<InlayHint> collector) {
return new ASTVisitor() {

@Override
public boolean visit(NormalAnnotation node) {
EmbeddedCronExpression cron = extractCronExpression(node);
if (cron != null) {
processCron(project, doc, collector, cron, node);
}
return super.visit(node);
}

@Override
public boolean visit(SingleMemberAnnotation node) {
EmbeddedCronExpression cron = extractCronExpression(node);
if (cron != null) {
processCron(project, doc, collector, cron, node);
}
return super.visit(node);
}

};
}

private void processCron(IJavaProject project, TextDocument doc, Collector<InlayHint> collector,
EmbeddedCronExpression cronExp, Annotation node) {
boolean isValidExpression = CronExpression.isValidExpression(cronExp.text());

try {
if (isValidExpression) {
CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(SPRING);
CronParser parser = new CronParser(cronDefinition);
CronDescriptor descriptor = CronDescriptor.instance(Locale.US);
String cronDescription = descriptor.describe(parser.parse(cronExp.text().toUpperCase()));

InlayHint hint = new InlayHint();
hint.setKind(InlayHintKind.Type);
hint.setLabel(Either.forLeft(cronDescription));
hint.setTooltip(cronDescription);
hint.setPaddingLeft(true);
hint.setPaddingRight(true);
hint.setPosition(doc.toPosition(node.getStartPosition() + node.getLength()));
collector.accept(hint);
}
} catch (Exception e) {
// ignore
}
}

public static EmbeddedCronExpression extractCronExpression(SingleMemberAnnotation a) {
if (isScheduledAnnotation(a)) {
EmbeddedCronExpression expression = extractEmbeddedExpression(a.getValue(), a);
return expression == null ? null
: new EmbeddedCronExpression(expression.expression(), expression.text(), expression.offset());
}
return null;
}

public static EmbeddedCronExpression extractCronExpression(NormalAnnotation a) {
Expression cronExpression = null;
if (isScheduledAnnotation(a)) {
for (Object value : a.values()) {
if (value instanceof MemberValuePair) {
MemberValuePair pair = (MemberValuePair) value;
String name = pair.getName().getFullyQualifiedName();
if ("cron".equals(name)) {
cronExpression = pair.getValue();
break;
}
}
}
}
if (cronExpression != null) {
EmbeddedCronExpression e = extractEmbeddedExpression(cronExpression, a);
if (e != null) {
return new EmbeddedCronExpression(e.expression(), e.text(), e.offset());
}
}
return null;
}

public static EmbeddedCronExpression extractEmbeddedExpression(Expression valueExp, Annotation node) {
String text = null;
int offset = 0;
if (valueExp instanceof StringLiteral sl) {
text = sl.getEscapedValue();
text = text.substring(1, text.length() - 1);
offset = sl.getStartPosition() + 1; // +1 to skip over opening "
} else if (valueExp instanceof TextBlock tb) {
text = tb.getEscapedValue();
text = text.substring(3, text.length() - 3).trim();
offset = tb.getStartPosition() + 3; // +3 to skip over opening """
}
return text == null ? null : new EmbeddedCronExpression(valueExp, text, offset);
}

static boolean isScheduledAnnotation(Annotation a) {
return Annotations.SCHEDULED.equals(a.getTypeName().getFullyQualifiedName())
|| SCHEDULED.equals(a.getTypeName().getFullyQualifiedName());
}
}
Loading