Skip to content

Commit 4324c68

Browse files
authored
Support linked correction proposals through LSP snippet syntax (#3229)
- Support placeholder & choice syntaxes - Add API for SnippetTextEdit & StringValue - Add testcase
1 parent 67ff3b0 commit 4324c68

File tree

5 files changed

+276
-2
lines changed

5 files changed

+276
-2
lines changed

org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/SnippetUtils.java

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,33 @@
1313

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

16+
import java.util.ArrayList;
17+
import java.util.Arrays;
18+
import java.util.Iterator;
19+
import java.util.List;
20+
21+
import org.eclipse.core.runtime.CoreException;
22+
import org.eclipse.jdt.core.IBuffer;
23+
import org.eclipse.jdt.core.ICompilationUnit;
24+
import org.eclipse.jdt.internal.corext.fix.LinkedProposalModelCore;
25+
import org.eclipse.jdt.internal.corext.fix.LinkedProposalPositionGroupCore;
26+
import org.eclipse.jdt.internal.corext.fix.LinkedProposalPositionGroupCore.PositionInformation;
27+
import org.eclipse.jdt.internal.corext.fix.LinkedProposalPositionGroupCore.ProposalCore;
28+
import org.eclipse.jdt.internal.ui.text.correction.proposals.LinkedCorrectionProposalCore;
29+
import org.eclipse.jdt.ls.core.internal.JDTUtils;
1630
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
31+
import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers;
32+
import org.eclipse.jface.text.link.LinkedModeModel;
33+
import org.eclipse.jface.text.link.LinkedPosition;
1734
import org.eclipse.lsp4j.MarkupContent;
1835
import org.eclipse.lsp4j.MarkupKind;
36+
import org.eclipse.lsp4j.Range;
37+
import org.eclipse.lsp4j.TextEdit;
38+
import org.eclipse.lsp4j.WorkspaceEdit;
39+
import org.eclipse.lsp4j.extended.SnippetTextEdit;
1940
import org.eclipse.lsp4j.jsonrpc.messages.Either;
41+
import org.eclipse.text.edits.MultiTextEdit;
42+
import org.eclipse.text.edits.ReplaceEdit;
2043

2144
public class SnippetUtils {
2245

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

51+
public static final String SNIPPET_PREFIX = "${";
52+
public static final char SNIPPET_CHOICE_INDICATOR = '|';
53+
public static final String SNIPPET_CHOICE_POSTFIX = "|}";
54+
public static final String SNIPPET_CHOICE_SEPARATOR = ",";
55+
2856
private SnippetUtils() {
2957
}
3058

@@ -64,4 +92,154 @@ public static Either<String, MarkupContent> beautifyDocument(String raw) {
6492
return Either.forLeft(escapedString);
6593
}
6694
}
95+
96+
/***
97+
* Supports linked correction proposals by converting them to snippets.
98+
* Represents a
99+
* {@link org.eclipse.jdt.internal.corext.fix.LinkedProposalPositionGroupCore}
100+
* with snippet choice syntax if the group has multiple proposals, otherwise
101+
* represents it as a placeholder.
102+
*
103+
* @param proposal
104+
* the proposal to add support for
105+
* @param edit
106+
* the current edit to be returned with the code action
107+
* @throws CoreException
108+
*/
109+
public static final void addSnippetsIfApplicable(LinkedCorrectionProposalCore proposal, WorkspaceEdit edit) throws CoreException {
110+
ICompilationUnit compilationUnit = proposal.getCompilationUnit();
111+
String proposalUri = JDTUtils.toURI(compilationUnit);
112+
IBuffer buffer = compilationUnit.getBuffer();
113+
LinkedProposalModelCore linkedProposals = proposal.getLinkedProposalModel();
114+
List<Triple> snippets = new ArrayList<>();
115+
Iterator<LinkedProposalPositionGroupCore> it = linkedProposals.getPositionGroupCoreIterator();
116+
117+
while (it.hasNext()) {
118+
LinkedProposalPositionGroupCore group = it.next();
119+
ProposalCore[] proposalList = group.getProposals();
120+
PositionInformation[] positionList = group.getPositions();
121+
// Sorts in ascending order to ensure first position in list has the smallest offset
122+
Arrays.sort(positionList, (p1, p2) -> {
123+
return p1.getOffset() - p2.getOffset();
124+
});
125+
StringBuilder snippet = new StringBuilder();
126+
snippet.append(SNIPPET_CHOICE_INDICATOR);
127+
128+
for (int i = 0; i < positionList.length; i++) {
129+
int offset = positionList[i].getOffset();
130+
int length = positionList[i].getLength();
131+
132+
// Create snippet on first iteration
133+
if (i == 0) {
134+
LinkedPosition linkedPosition = new LinkedPosition(JsonRpcHelpers.toDocument(buffer), positionList[i].getOffset(), positionList[i].getLength(), positionList[i].getSequenceRank());
135+
136+
// Groups with no proposals will have the snippet text added while amending the WorkspaceEdit
137+
for (int j = 0; j < proposalList.length; j++) {
138+
org.eclipse.text.edits.TextEdit editWithText = findReplaceEdit(proposalList[j].computeEdits(0, linkedPosition, '\u0000', 0, new LinkedModeModel()));
139+
if (editWithText != null) {
140+
snippet.append(((ReplaceEdit) editWithText).getText());
141+
snippet.append(SNIPPET_CHOICE_SEPARATOR);
142+
}
143+
}
144+
if (String.valueOf(snippet.charAt(snippet.length() - 1)).equals(SNIPPET_CHOICE_SEPARATOR)) {
145+
snippet.deleteCharAt(snippet.length() - 1);
146+
}
147+
snippet.append(SNIPPET_CHOICE_POSTFIX);
148+
// If snippet only has one choice, remove choice indicators
149+
if (snippet.indexOf(SNIPPET_CHOICE_SEPARATOR) == -1) {
150+
snippet.setCharAt(0, ':');
151+
snippet.deleteCharAt(snippet.length() - 2);
152+
}
153+
// Snippet is added with smallest offset as 0th element
154+
snippets.add(new Triple(snippet.toString(), offset, length));
155+
} else {
156+
// Add offset/length values from additional positions in group to previously created snippet
157+
Triple currentSnippet = snippets.get(snippets.size() - 1);
158+
currentSnippet.offset.add(offset);
159+
currentSnippet.length.add(length);
160+
}
161+
}
162+
}
163+
if (!snippets.isEmpty()) {
164+
// Sort snippets based on offset of earliest occurrence to enable correct numbering
165+
snippets.sort(null);
166+
int snippetNumber = 1;
167+
for (int i = snippets.size() - 1; i >= 0; i--) {
168+
Triple element = snippets.get(i);
169+
element.snippet = SNIPPET_PREFIX + snippetNumber + element.snippet;
170+
snippetNumber++;
171+
// Separate snippets with multiple positions into individual instances in list
172+
for (int j = 1; j < element.offset.size(); j++) {
173+
snippets.add(new Triple(element.snippet.toString(), element.offset.get(j), element.length.get(j)));
174+
element.offset.remove(j);
175+
element.length.remove(j);
176+
}
177+
}
178+
// Re-sort snippets (with the added individual instances) by offset in descending order,
179+
// so that the amendments to the text edit are applied in an order that does not alter the offset of later amendments
180+
snippets.sort(null);
181+
for (int i = 0; i < edit.getDocumentChanges().size(); i++) {
182+
if (edit.getDocumentChanges().get(i).isLeft()) {
183+
List<TextEdit> edits = edit.getDocumentChanges().get(i).getLeft().getEdits();
184+
String editUri = edit.getDocumentChanges().get(i).getLeft().getTextDocument().getUri();
185+
for (int j = 0; j < edits.size(); j++) {
186+
Range editRange = edits.get(j).getRange();
187+
StringBuilder replacementText = new StringBuilder(edits.get(j).getNewText());
188+
int rangeStart = JsonRpcHelpers.toOffset(buffer, editRange.getStart().getLine(), editRange.getStart().getCharacter());
189+
int rangeEnd = rangeStart + replacementText.length();
190+
191+
for (int k = 0; k < snippets.size(); k++) {
192+
Triple currentSnippet = snippets.get(k);
193+
if (proposalUri.equals(editUri) && currentSnippet.offset.get(0) >= rangeStart && currentSnippet.offset.get(0) <= rangeEnd) {
194+
int replaceStart = currentSnippet.offset.get(0) - rangeStart;
195+
int replaceEnd = replaceStart + currentSnippet.length.get(0);
196+
// 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
197+
if (currentSnippet.snippet.endsWith(":}")) {
198+
currentSnippet.snippet = currentSnippet.snippet.replaceFirst(":", ":" + replacementText.substring(replaceStart, replaceEnd));
199+
}
200+
replacementText.replace(replaceStart, replaceEnd, currentSnippet.snippet);
201+
}
202+
}
203+
204+
SnippetTextEdit newEdit = new SnippetTextEdit(editRange, replacementText.toString());
205+
edits.set(j, newEdit);
206+
}
207+
}
208+
}
209+
}
210+
}
211+
212+
private static final org.eclipse.text.edits.TextEdit findReplaceEdit(org.eclipse.text.edits.TextEdit edit) {
213+
if (edit instanceof ReplaceEdit && !((ReplaceEdit) edit).getText().isBlank()) {
214+
return edit;
215+
}
216+
217+
if (edit instanceof MultiTextEdit) {
218+
for (org.eclipse.text.edits.TextEdit child : edit.getChildren()) {
219+
org.eclipse.text.edits.TextEdit replaceEdit = findReplaceEdit(child);
220+
if (replaceEdit != null) {
221+
return replaceEdit;
222+
}
223+
}
224+
}
225+
return null;
226+
}
227+
228+
private static final class Triple implements Comparable<Triple> {
229+
public String snippet;
230+
public List<Integer> offset = new ArrayList<>();
231+
public List<Integer> length = new ArrayList<>();
232+
233+
Triple(String snippet, int offset, int length) {
234+
this.snippet = snippet;
235+
this.offset.add(offset);
236+
this.length.add(length);
237+
}
238+
239+
// Sorts in descending order based on 0th (smallest) element of offset list
240+
@Override
241+
public int compareTo(Triple other) {
242+
return other.offset.get(0) - this.offset.get(0);
243+
}
244+
}
67245
}

org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/handlers/CodeActionResolveHandler.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
import org.eclipse.core.runtime.CoreException;
1919
import org.eclipse.core.runtime.IProgressMonitor;
2020
import org.eclipse.jdt.core.manipulation.ChangeCorrectionProposalCore;
21+
import org.eclipse.jdt.internal.ui.text.correction.proposals.LinkedCorrectionProposalCore;
2122
import org.eclipse.jdt.ls.core.internal.ChangeUtil;
2223
import org.eclipse.jdt.ls.core.internal.JSONUtility;
2324
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
25+
import org.eclipse.jdt.ls.core.internal.contentassist.SnippetUtils;
2426
import org.eclipse.lsp4j.CodeAction;
2527
import org.eclipse.lsp4j.WorkspaceEdit;
2628
import org.eclipse.lsp4j.jsonrpc.messages.Either;
@@ -46,8 +48,11 @@ public CodeAction resolve(CodeAction params, IProgressMonitor monitor) {
4648

4749
try {
4850
Either<ChangeCorrectionProposalCore, CodeActionProposal> proposal = response.getProposals().get(proposalId);
49-
WorkspaceEdit edit = proposal.isLeft() ? ChangeUtil.convertToWorkspaceEdit(proposal.getLeft().getChange())
50-
: proposal.getRight().resolveEdit(monitor);
51+
WorkspaceEdit edit = proposal.isLeft() ? ChangeUtil.convertToWorkspaceEdit(proposal.getLeft().getChange()) : proposal.getRight().resolveEdit(monitor);
52+
if (JavaLanguageServerPlugin.getPreferencesManager().getClientPreferences().isWorkspaceSnippetEditSupported() && edit.getDocumentChanges() != null
53+
&& proposal.isLeft() && proposal.getLeft() instanceof LinkedCorrectionProposalCore) {
54+
SnippetUtils.addSnippetsIfApplicable((LinkedCorrectionProposalCore) proposal.getLeft(), edit);
55+
}
5156
if (ChangeUtil.hasChanges(edit)) {
5257
params.setEdit(edit);
5358
}

org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/ClientPreferences.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ public boolean isWorkspaceApplyEditSupported() {
195195
return capabilities.getWorkspace() != null && isTrue(capabilities.getWorkspace().getApplyEdit());
196196
}
197197

198+
public boolean isWorkspaceSnippetEditSupported() {
199+
return Boolean.parseBoolean(extendedClientCapabilities.getOrDefault("snippetEditSupport", "false").toString());
200+
}
201+
198202
public boolean isProgressReportSupported() {
199203
return Boolean.parseBoolean(extendedClientCapabilities.getOrDefault("progressReportProvider", "false").toString());
200204
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2024 Red Hat Inc. and others.
3+
* All rights reserved. This program and the accompanying materials
4+
* are made available under the terms of the Eclipse Public License 2.0
5+
* which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-2.0
7+
*
8+
* SPDX-License-Identifier: EPL-2.0
9+
*
10+
* Contributors:
11+
* Red Hat Inc. - initial API and implementation
12+
*******************************************************************************/
13+
14+
package org.eclipse.lsp4j.extended;
15+
16+
import org.eclipse.lsp4j.Range;
17+
import org.eclipse.lsp4j.TextEdit;
18+
19+
public class SnippetTextEdit extends TextEdit {
20+
StringValue snippet;
21+
22+
public SnippetTextEdit(Range range, String snippet) {
23+
super(range, "");
24+
this.snippet = new StringValue(snippet);
25+
}
26+
27+
public StringValue getSnippet() {
28+
return snippet;
29+
}
30+
31+
public static final class StringValue {
32+
public static final String kind = "snippet";
33+
String value;
34+
35+
StringValue(String value) {
36+
this.value = value;
37+
}
38+
39+
public String getValue() {
40+
return value;
41+
}
42+
}
43+
}

org.eclipse.jdt.ls.tests/src/org/eclipse/jdt/ls/core/internal/handlers/CodeActionResolveHandlerTest.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151
import org.eclipse.lsp4j.Position;
5252
import org.eclipse.lsp4j.Range;
5353
import org.eclipse.lsp4j.TextDocumentIdentifier;
54+
import org.eclipse.lsp4j.TextEdit;
55+
import org.eclipse.lsp4j.extended.SnippetTextEdit;
5456
import org.eclipse.lsp4j.jsonrpc.messages.Either;
5557
import org.junit.Assert;
5658
import org.junit.Before;
@@ -360,6 +362,48 @@ public void testResolveCodeAction_SourceActions() throws Exception {
360362
Assert.assertEquals(buf.toString(), actual);
361363
}
362364

365+
@Test
366+
public void testResolveCodeAction_LinkedProposals() throws Exception {
367+
when(preferenceManager.getClientPreferences().isResolveCodeActionSupported()).thenReturn(true);
368+
when(preferenceManager.getClientPreferences().isWorkspaceSnippetEditSupported()).thenReturn(true);
369+
when(preferenceManager.getClientPreferences().isResourceOperationSupported()).thenReturn(true);
370+
371+
StringBuilder buf = new StringBuilder();
372+
buf.append("import java.util.HashMap;\n");
373+
buf.append("import java.util.List;\n");
374+
buf.append("public class E {\n");
375+
buf.append(" private void hello() {\n");
376+
buf.append(" List<String> test = new HashMap();\n");
377+
buf.append(" }\n");
378+
buf.append("}\n");
379+
ICompilationUnit unit = defaultPackage.createCompilationUnit("E.java", buf.toString(), false, null);
380+
CodeActionParams params = new CodeActionParams();
381+
params.setTextDocument(new TextDocumentIdentifier(JDTUtils.toURI(unit)));
382+
final Range range = CodeActionUtil.getRange(unit, "new HashMap()");
383+
params.setRange(range);
384+
CodeActionContext context = new CodeActionContext(Arrays.asList(getDiagnostic(Integer.toString(IProblem.TypeMismatch), range)), Collections.singletonList(CodeActionKind.QuickFix));
385+
params.setContext(context);
386+
387+
List<Either<Command, CodeAction>> quickfixActions = server.codeAction(params).join();
388+
Assert.assertFalse("No quickfix actions were found", quickfixActions.isEmpty());
389+
for (Either<Command, CodeAction> codeAction : quickfixActions) {
390+
Assert.assertNull("Should defer the edit property to the resolveCodeAction request", codeAction.getRight().getEdit());
391+
Assert.assertNotNull("Should preserve the data property for the unresolved code action", codeAction.getRight().getData());
392+
}
393+
394+
Optional<Either<Command, CodeAction>> removeUnusedResponse = quickfixActions.stream().filter(codeAction -> {
395+
return "Change type of 'test' to 'HashMap'".equals(codeAction.getRight().getTitle());
396+
}).findFirst();
397+
Assert.assertTrue("Should return the quickfix \"Change type of 'test' to 'HashMap'\"", removeUnusedResponse.isPresent());
398+
CodeAction unresolvedCodeAction = removeUnusedResponse.get().getRight();
399+
400+
CodeAction resolvedCodeAction = server.resolveCodeAction(unresolvedCodeAction).join();
401+
Assert.assertNotNull("Should resolve the edit property in the resolveCodeAction request", resolvedCodeAction.getEdit());
402+
List<TextEdit> edits = resolvedCodeAction.getEdit().getDocumentChanges().get(0).getLeft().getEdits();
403+
Assert.assertTrue(edits.get(0) instanceof SnippetTextEdit);
404+
Assert.assertEquals(((SnippetTextEdit) edits.get(0)).getSnippet().getValue(), "${1|HashMap,Map,Cloneable,Serializable,AbstractMap,Object|}");
405+
}
406+
363407
private Diagnostic getDiagnostic(String code, Range range){
364408
Diagnostic $ = new Diagnostic();
365409
$.setCode(code);

0 commit comments

Comments
 (0)