diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/SnippetUtils.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/SnippetUtils.java index c8bea9cd6b..57e3ad90e8 100644 --- a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/SnippetUtils.java +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/SnippetUtils.java @@ -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 { @@ -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() { } @@ -64,4 +92,154 @@ public static Either 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 snippets = new ArrayList<>(); + Iterator 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 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 { + public String snippet; + public List offset = new ArrayList<>(); + public List 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); + } + } } diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/handlers/CodeActionResolveHandler.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/handlers/CodeActionResolveHandler.java index 905c2f7ff1..0c4c5405e1 100644 --- a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/handlers/CodeActionResolveHandler.java +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/handlers/CodeActionResolveHandler.java @@ -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; @@ -46,8 +48,11 @@ public CodeAction resolve(CodeAction params, IProgressMonitor monitor) { try { Either 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); } diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/ClientPreferences.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/ClientPreferences.java index 4ad13ba811..912319173b 100644 --- a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/ClientPreferences.java +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/ClientPreferences.java @@ -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()); + } + public boolean isProgressReportSupported() { return Boolean.parseBoolean(extendedClientCapabilities.getOrDefault("progressReportProvider", "false").toString()); } diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/lsp4j/extended/SnippetTextEdit.java b/org.eclipse.jdt.ls.core/src/org/eclipse/lsp4j/extended/SnippetTextEdit.java new file mode 100644 index 0000000000..fb1efe57b4 --- /dev/null +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/lsp4j/extended/SnippetTextEdit.java @@ -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; + } + } +} \ No newline at end of file diff --git a/org.eclipse.jdt.ls.tests/src/org/eclipse/jdt/ls/core/internal/handlers/CodeActionResolveHandlerTest.java b/org.eclipse.jdt.ls.tests/src/org/eclipse/jdt/ls/core/internal/handlers/CodeActionResolveHandlerTest.java index 6a677573af..3faf65c750 100644 --- a/org.eclipse.jdt.ls.tests/src/org/eclipse/jdt/ls/core/internal/handlers/CodeActionResolveHandlerTest.java +++ b/org.eclipse.jdt.ls.tests/src/org/eclipse/jdt/ls/core/internal/handlers/CodeActionResolveHandlerTest.java @@ -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; @@ -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 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> quickfixActions = server.codeAction(params).join(); + Assert.assertFalse("No quickfix actions were found", quickfixActions.isEmpty()); + for (Either 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> 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 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);