diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java index bf7a24d88..f95e037b5 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java @@ -73,21 +73,45 @@ public class LSPIJUtils { private static final String ENCODED_HASH_SEPARATOR = "%23"; - private static final Comparator TEXT_EDITS_ASCENDING_COMPARATOR = (a, b) -> { - int diff = a.getRange().getStart().getLine() - b.getRange().getStart().getLine(); - if (diff == 0) { - return a.getRange().getStart().getCharacter() - b.getRange().getStart().getCharacter(); + /** + * Helper record to track original index of TextEdit for stable sorting. + */ + private record IndexedTextEdit(int index, TextEdit edit) {} + + /** + * Sort text edits by position, preserving array order for edits at the same position + * as required by LSP spec. + * + * @param edits the text edits to sort + * @return a new sorted list of text edits + */ + private static List sortTextEdits(List edits) { + // No need to sort if there's 0 or 1 edit + if (edits.size() <= 1) { + return new ArrayList<>(edits); } - return diff; - }; - private static final Comparator TEXT_EDITS_DESCENDING_COMPARATOR = (a, b) -> { - int diff = b.getRange().getStart().getLine() - a.getRange().getStart().getLine(); - if (diff == 0) { - return b.getRange().getStart().getCharacter() - a.getRange().getStart().getCharacter(); + // Create indexed entries to preserve original order for edits at the same position + List indexedEdits = new ArrayList<>(edits.size()); + for (int i = 0; i < edits.size(); i++) { + indexedEdits.add(new IndexedTextEdit(i, edits.get(i))); } - return diff; - }; + + // Sort text edits by position, then by index when positions are equal. + // The index preserves the original array order for same-position edits, + // as required by the LSP spec. + indexedEdits.sort(Comparator + .comparingInt((IndexedTextEdit e) -> e.edit.getRange().getStart().getLine()) + .thenComparingInt(e -> e.edit.getRange().getStart().getCharacter()) + .thenComparingInt((IndexedTextEdit e) -> e.index)); + + // Extract sorted edits + List result = new ArrayList<>(indexedEdits.size()); + for (IndexedTextEdit indexed : indexedEdits) { + result.add(indexed.edit); + } + return result; + } /** * Open the LSP location in an editor. @@ -1163,6 +1187,11 @@ private static void doApplyEdits(@Nullable Editor editor, @NotNull List edits, boolean saveDocument) { // Create an owned copy to insulate against modification of the provided list while processing it + // We don't sort here because: + // 1. RangeMarkers capture positions from the original document (S1 in LSP spec) + // 2. All edits refer to the original document, not intermediate states + // 3. RangeMarkers automatically adjust as edits are applied + // 4. The original array order must be preserved for same-position edits List ownedEdits = new ArrayList<>(edits); if (ownedEdits.isEmpty()) { @@ -1317,13 +1346,9 @@ private static int getIncrement(int start, int end, int caret) { */ public static String applyEdits(@NotNull Document document, @NotNull List edits) { - // Create an owned mutable copy since we're going to modify the list - List mutableEdits = new ArrayList<>(edits); + // Sort text edits by position, preserving array order for same-position edits + List mutableEdits = sortTextEdits(edits); - // Sort text edits - if (mutableEdits.size() > 1) { - mutableEdits.sort(TEXT_EDITS_ASCENDING_COMPARATOR); - } String text = document.getText(); int lastModifiedOffset = 0; List spans = new ArrayList<>(mutableEdits.size() + 1); diff --git a/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_applyEdits_SamePositionTest.java b/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_applyEdits_SamePositionTest.java new file mode 100644 index 000000000..28785e587 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_applyEdits_SamePositionTest.java @@ -0,0 +1,89 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij; + +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.impl.DocumentImpl; +import com.intellij.testFramework.fixtures.BasePlatformTestCase; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +/** + * Tests for {@link LSPIJUtils#applyEdits(Document, List)} with multiple text edits at the same position. + * According to LSP spec: "if multiple inserts have the same position, the order in the array + * defines the order in which the inserted strings appear in the resulting text." + * + * This method is used for formatting, where text edits must be sorted by position and + * same-position edits must preserve array order. + */ +public class LSPIJUtils_applyEdits_SamePositionTest extends BasePlatformTestCase { + + /** + * Test that multiple insertions at the same position maintain array order. + */ + public void testMultipleInsertsAtSamePosition() { + Document document = new DocumentImpl("world"); + + List edits = new ArrayList<>(); + // Insert three strings at position 0:0 in order: "Hello ", ", ", "beautiful " + // Expected result: "Hello , beautiful world" (in that exact order) + edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "Hello ")); + edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), ", ")); + edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "beautiful ")); + + String result = LSPIJUtils.applyEdits(document, edits); + assertEquals("Hello , beautiful world", result); + } + + /** + * Test multiple insertions at the same position with mixed line/character positions. + */ + public void testMultipleInsertsAtSamePositionMidLine() { + Document document = new DocumentImpl("start end"); + + List edits = new ArrayList<>(); + // Insert three strings at position 0:6 (between "start " and "end") + // In order: "one ", "two ", "three " + edits.add(new TextEdit(new Range(new Position(0, 6), new Position(0, 6)), "one ")); + edits.add(new TextEdit(new Range(new Position(0, 6), new Position(0, 6)), "two ")); + edits.add(new TextEdit(new Range(new Position(0, 6), new Position(0, 6)), "three ")); + + String result = LSPIJUtils.applyEdits(document, edits); + assertEquals("start one two three end", result); + } + + /** + * Test that edits maintain order even when mixed with edits at different positions. + */ + public void testMixedPositionEditsWithSamePositionInserts() { + Document document = new DocumentImpl("line1\nline2\nline3\n"); + + List edits = new ArrayList<>(); + // Multiple edits: + // - Two inserts at 0:0 (should maintain order: "A", "B") + // - One insert at 1:0 + // - Two inserts at 2:0 (should maintain order: "X", "Y") + edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "A")); + edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "B")); + edits.add(new TextEdit(new Range(new Position(1, 0), new Position(1, 0)), "C")); + edits.add(new TextEdit(new Range(new Position(2, 0), new Position(2, 0)), "X")); + edits.add(new TextEdit(new Range(new Position(2, 0), new Position(2, 0)), "Y")); + + String result = LSPIJUtils.applyEdits(document, edits); + assertEquals("ABline1\nCline2\nXYline3\n", result); + } +}