Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,33 @@

package org.eclipse.jdt.ls.core.internal.contentassist;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;

import org.eclipse.core.runtime.CoreException;
import org.eclipse.jdt.core.IBuffer;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.internal.corext.fix.LinkedProposalModelCore;
import org.eclipse.jdt.internal.corext.fix.LinkedProposalPositionGroupCore;
import org.eclipse.jdt.internal.corext.fix.LinkedProposalPositionGroupCore.PositionInformation;
import org.eclipse.jdt.internal.corext.fix.LinkedProposalPositionGroupCore.ProposalCore;
import org.eclipse.jdt.internal.ui.text.correction.proposals.LinkedCorrectionProposalCore;
import org.eclipse.jdt.ls.core.internal.JDTUtils;
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers;
import org.eclipse.jface.text.link.LinkedModeModel;
import org.eclipse.jface.text.link.LinkedPosition;
import org.eclipse.lsp4j.MarkupContent;
import org.eclipse.lsp4j.MarkupKind;
import org.eclipse.lsp4j.Range;
import org.eclipse.lsp4j.TextEdit;
import org.eclipse.lsp4j.WorkspaceEdit;
import org.eclipse.lsp4j.extended.SnippetTextEdit;
import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.eclipse.text.edits.MultiTextEdit;
import org.eclipse.text.edits.ReplaceEdit;

public class SnippetUtils {

Expand All @@ -25,6 +48,11 @@ public class SnippetUtils {
private static final String TM_SELECTED_TEXT = "\\$TM_SELECTED_TEXT";
private static final String TM_FILENAME_BASE = "\\$TM_FILENAME_BASE";

public static final String SNIPPET_PREFIX = "${";
public static final char SNIPPET_CHOICE_INDICATOR = '|';
public static final String SNIPPET_CHOICE_POSTFIX = "|}";
public static final String SNIPPET_CHOICE_SEPARATOR = ",";

private SnippetUtils() {
}

Expand Down Expand Up @@ -64,4 +92,154 @@ public static Either<String, MarkupContent> beautifyDocument(String raw) {
return Either.forLeft(escapedString);
}
}

/***
* Supports linked correction proposals by converting them to snippets.
* Represents a
* {@link org.eclipse.jdt.internal.corext.fix.LinkedProposalPositionGroupCore}
* with snippet choice syntax if the group has multiple proposals, otherwise
* represents it as a placeholder.
*
* @param proposal
* the proposal to add support for
* @param edit
* the current edit to be returned with the code action
* @throws CoreException
*/
public static final void addSnippetsIfApplicable(LinkedCorrectionProposalCore proposal, WorkspaceEdit edit) throws CoreException {
ICompilationUnit compilationUnit = proposal.getCompilationUnit();
String proposalUri = JDTUtils.toURI(compilationUnit);
IBuffer buffer = compilationUnit.getBuffer();
LinkedProposalModelCore linkedProposals = proposal.getLinkedProposalModel();
List<Triple> snippets = new ArrayList<>();
Iterator<LinkedProposalPositionGroupCore> it = linkedProposals.getPositionGroupCoreIterator();

while (it.hasNext()) {
LinkedProposalPositionGroupCore group = it.next();
ProposalCore[] proposalList = group.getProposals();
PositionInformation[] positionList = group.getPositions();
// Sorts in ascending order to ensure first position in list has the smallest offset
Arrays.sort(positionList, (p1, p2) -> {
return p1.getOffset() - p2.getOffset();
});
StringBuilder snippet = new StringBuilder();
snippet.append(SNIPPET_CHOICE_INDICATOR);

for (int i = 0; i < positionList.length; i++) {
int offset = positionList[i].getOffset();
int length = positionList[i].getLength();

// Create snippet on first iteration
if (i == 0) {
LinkedPosition linkedPosition = new LinkedPosition(JsonRpcHelpers.toDocument(buffer), positionList[i].getOffset(), positionList[i].getLength(), positionList[i].getSequenceRank());

// Groups with no proposals will have the snippet text added while amending the WorkspaceEdit
for (int j = 0; j < proposalList.length; j++) {
org.eclipse.text.edits.TextEdit editWithText = findReplaceEdit(proposalList[j].computeEdits(0, linkedPosition, '\u0000', 0, new LinkedModeModel()));
if (editWithText != null) {
snippet.append(((ReplaceEdit) editWithText).getText());
snippet.append(SNIPPET_CHOICE_SEPARATOR);
}
}
if (String.valueOf(snippet.charAt(snippet.length() - 1)).equals(SNIPPET_CHOICE_SEPARATOR)) {
snippet.deleteCharAt(snippet.length() - 1);
}
snippet.append(SNIPPET_CHOICE_POSTFIX);
// If snippet only has one choice, remove choice indicators
if (snippet.indexOf(SNIPPET_CHOICE_SEPARATOR) == -1) {
snippet.setCharAt(0, ':');
snippet.deleteCharAt(snippet.length() - 2);
}
// Snippet is added with smallest offset as 0th element
snippets.add(new Triple(snippet.toString(), offset, length));
} else {
// Add offset/length values from additional positions in group to previously created snippet
Triple currentSnippet = snippets.get(snippets.size() - 1);
currentSnippet.offset.add(offset);
currentSnippet.length.add(length);
}
}
}
if (!snippets.isEmpty()) {
// Sort snippets based on offset of earliest occurrence to enable correct numbering
snippets.sort(null);
int snippetNumber = 1;
for (int i = snippets.size() - 1; i >= 0; i--) {
Triple element = snippets.get(i);
element.snippet = SNIPPET_PREFIX + snippetNumber + element.snippet;
snippetNumber++;
// Separate snippets with multiple positions into individual instances in list
for (int j = 1; j < element.offset.size(); j++) {
snippets.add(new Triple(element.snippet.toString(), element.offset.get(j), element.length.get(j)));
element.offset.remove(j);
element.length.remove(j);
}
}
// Re-sort snippets (with the added individual instances) by offset in descending order,
// so that the amendments to the text edit are applied in an order that does not alter the offset of later amendments
snippets.sort(null);
for (int i = 0; i < edit.getDocumentChanges().size(); i++) {
if (edit.getDocumentChanges().get(i).isLeft()) {
List<TextEdit> edits = edit.getDocumentChanges().get(i).getLeft().getEdits();
String editUri = edit.getDocumentChanges().get(i).getLeft().getTextDocument().getUri();
for (int j = 0; j < edits.size(); j++) {
Range editRange = edits.get(j).getRange();
StringBuilder replacementText = new StringBuilder(edits.get(j).getNewText());
int rangeStart = JsonRpcHelpers.toOffset(buffer, editRange.getStart().getLine(), editRange.getStart().getCharacter());
int rangeEnd = rangeStart + replacementText.length();

for (int k = 0; k < snippets.size(); k++) {
Triple currentSnippet = snippets.get(k);
if (proposalUri.equals(editUri) && currentSnippet.offset.get(0) >= rangeStart && currentSnippet.offset.get(0) <= rangeEnd) {
int replaceStart = currentSnippet.offset.get(0) - rangeStart;
int replaceEnd = replaceStart + currentSnippet.length.get(0);
// If snippet text has not been added due to no elements in the proposal list, create snippet based on the text in the given position range
if (currentSnippet.snippet.endsWith(":}")) {
currentSnippet.snippet = currentSnippet.snippet.replaceFirst(":", ":" + replacementText.substring(replaceStart, replaceEnd));
}
replacementText.replace(replaceStart, replaceEnd, currentSnippet.snippet);
}
}

SnippetTextEdit newEdit = new SnippetTextEdit(editRange, replacementText.toString());
edits.set(j, newEdit);
}
}
}
}
}

private static final org.eclipse.text.edits.TextEdit findReplaceEdit(org.eclipse.text.edits.TextEdit edit) {
if (edit instanceof ReplaceEdit && !((ReplaceEdit) edit).getText().isBlank()) {
return edit;
}

if (edit instanceof MultiTextEdit) {
for (org.eclipse.text.edits.TextEdit child : edit.getChildren()) {
org.eclipse.text.edits.TextEdit replaceEdit = findReplaceEdit(child);
if (replaceEdit != null) {
return replaceEdit;
}
}
}
return null;
}

private static final class Triple implements Comparable<Triple> {
public String snippet;
public List<Integer> offset = new ArrayList<>();
public List<Integer> length = new ArrayList<>();

Triple(String snippet, int offset, int length) {
this.snippet = snippet;
this.offset.add(offset);
this.length.add(length);
}

// Sorts in descending order based on 0th (smallest) element of offset list
@Override
public int compareTo(Triple other) {
return other.offset.get(0) - this.offset.get(0);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.jdt.core.manipulation.ChangeCorrectionProposalCore;
import org.eclipse.jdt.internal.ui.text.correction.proposals.LinkedCorrectionProposalCore;
import org.eclipse.jdt.ls.core.internal.ChangeUtil;
import org.eclipse.jdt.ls.core.internal.JSONUtility;
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
import org.eclipse.jdt.ls.core.internal.contentassist.SnippetUtils;
import org.eclipse.lsp4j.CodeAction;
import org.eclipse.lsp4j.WorkspaceEdit;
import org.eclipse.lsp4j.jsonrpc.messages.Either;
Expand All @@ -46,8 +48,11 @@ public CodeAction resolve(CodeAction params, IProgressMonitor monitor) {

try {
Either<ChangeCorrectionProposalCore, CodeActionProposal> proposal = response.getProposals().get(proposalId);
WorkspaceEdit edit = proposal.isLeft() ? ChangeUtil.convertToWorkspaceEdit(proposal.getLeft().getChange())
: proposal.getRight().resolveEdit(monitor);
WorkspaceEdit edit = proposal.isLeft() ? ChangeUtil.convertToWorkspaceEdit(proposal.getLeft().getChange()) : proposal.getRight().resolveEdit(monitor);
if (JavaLanguageServerPlugin.getPreferencesManager().getClientPreferences().isWorkspaceSnippetEditSupported() && edit.getDocumentChanges() != null
&& proposal.isLeft() && proposal.getLeft() instanceof LinkedCorrectionProposalCore) {
SnippetUtils.addSnippetsIfApplicable((LinkedCorrectionProposalCore) proposal.getLeft(), edit);
}
if (ChangeUtil.hasChanges(edit)) {
params.setEdit(edit);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ public boolean isWorkspaceApplyEditSupported() {
return capabilities.getWorkspace() != null && isTrue(capabilities.getWorkspace().getApplyEdit());
}

public boolean isWorkspaceSnippetEditSupported() {
return Boolean.parseBoolean(extendedClientCapabilities.getOrDefault("snippetEditSupport", "false").toString());
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it be possible to use the standard workspaceEdit.snippetEditSupport capability here to make this work out of the box for clients supporting the 3.18 spec addition?

Copy link
Contributor

Choose a reason for hiding this comment

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

}

public boolean isProgressReportSupported() {
return Boolean.parseBoolean(extendedClientCapabilities.getOrDefault("progressReportProvider", "false").toString());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*******************************************************************************
* Copyright (c) 2024 Red Hat Inc. and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat Inc. - initial API and implementation
*******************************************************************************/

package org.eclipse.lsp4j.extended;

import org.eclipse.lsp4j.Range;
import org.eclipse.lsp4j.TextEdit;

public class SnippetTextEdit extends TextEdit {
StringValue snippet;

public SnippetTextEdit(Range range, String snippet) {
super(range, "");
this.snippet = new StringValue(snippet);
}

public StringValue getSnippet() {
return snippet;
}

public static final class StringValue {
public static final String kind = "snippet";
String value;

StringValue(String value) {
this.value = value;
}

public String getValue() {
return value;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
import org.eclipse.lsp4j.Position;
import org.eclipse.lsp4j.Range;
import org.eclipse.lsp4j.TextDocumentIdentifier;
import org.eclipse.lsp4j.TextEdit;
import org.eclipse.lsp4j.extended.SnippetTextEdit;
import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.junit.Assert;
import org.junit.Before;
Expand Down Expand Up @@ -360,6 +362,48 @@ public void testResolveCodeAction_SourceActions() throws Exception {
Assert.assertEquals(buf.toString(), actual);
}

@Test
public void testResolveCodeAction_LinkedProposals() throws Exception {
when(preferenceManager.getClientPreferences().isResolveCodeActionSupported()).thenReturn(true);
when(preferenceManager.getClientPreferences().isWorkspaceSnippetEditSupported()).thenReturn(true);
when(preferenceManager.getClientPreferences().isResourceOperationSupported()).thenReturn(true);

StringBuilder buf = new StringBuilder();
buf.append("import java.util.HashMap;\n");
buf.append("import java.util.List;\n");
buf.append("public class E {\n");
buf.append(" private void hello() {\n");
buf.append(" List<String> test = new HashMap();\n");
buf.append(" }\n");
buf.append("}\n");
ICompilationUnit unit = defaultPackage.createCompilationUnit("E.java", buf.toString(), false, null);
CodeActionParams params = new CodeActionParams();
params.setTextDocument(new TextDocumentIdentifier(JDTUtils.toURI(unit)));
final Range range = CodeActionUtil.getRange(unit, "new HashMap()");
params.setRange(range);
CodeActionContext context = new CodeActionContext(Arrays.asList(getDiagnostic(Integer.toString(IProblem.TypeMismatch), range)), Collections.singletonList(CodeActionKind.QuickFix));
params.setContext(context);

List<Either<Command, CodeAction>> quickfixActions = server.codeAction(params).join();
Assert.assertFalse("No quickfix actions were found", quickfixActions.isEmpty());
for (Either<Command, CodeAction> codeAction : quickfixActions) {
Assert.assertNull("Should defer the edit property to the resolveCodeAction request", codeAction.getRight().getEdit());
Assert.assertNotNull("Should preserve the data property for the unresolved code action", codeAction.getRight().getData());
}

Optional<Either<Command, CodeAction>> removeUnusedResponse = quickfixActions.stream().filter(codeAction -> {
return "Change type of 'test' to 'HashMap'".equals(codeAction.getRight().getTitle());
}).findFirst();
Assert.assertTrue("Should return the quickfix \"Change type of 'test' to 'HashMap'\"", removeUnusedResponse.isPresent());
CodeAction unresolvedCodeAction = removeUnusedResponse.get().getRight();

CodeAction resolvedCodeAction = server.resolveCodeAction(unresolvedCodeAction).join();
Assert.assertNotNull("Should resolve the edit property in the resolveCodeAction request", resolvedCodeAction.getEdit());
List<TextEdit> edits = resolvedCodeAction.getEdit().getDocumentChanges().get(0).getLeft().getEdits();
Assert.assertTrue(edits.get(0) instanceof SnippetTextEdit);
Assert.assertEquals(((SnippetTextEdit) edits.get(0)).getSnippet().getValue(), "${1|HashMap,Map,Cloneable,Serializable,AbstractMap,Object|}");
}

private Diagnostic getDiagnostic(String code, Range range){
Diagnostic $ = new Diagnostic();
$.setCode(code);
Expand Down