Skip to content

Commit d5ef58f

Browse files
committed
fix: Text edits at the same position are not applied in LSP spec order
Fixes #1404 Signed-off-by: azerr <azerr@redhat.com>
1 parent f3230bf commit d5ef58f

2 files changed

Lines changed: 132 additions & 18 deletions

File tree

src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -73,21 +73,45 @@ public class LSPIJUtils {
7373

7474
private static final String ENCODED_HASH_SEPARATOR = "%23";
7575

76-
private static final Comparator<TextEdit> TEXT_EDITS_ASCENDING_COMPARATOR = (a, b) -> {
77-
int diff = a.getRange().getStart().getLine() - b.getRange().getStart().getLine();
78-
if (diff == 0) {
79-
return a.getRange().getStart().getCharacter() - b.getRange().getStart().getCharacter();
76+
/**
77+
* Helper record to track original index of TextEdit for stable sorting.
78+
*/
79+
private record IndexedTextEdit(int index, TextEdit edit) {}
80+
81+
/**
82+
* Sort text edits by position, preserving array order for edits at the same position
83+
* as required by LSP spec.
84+
*
85+
* @param edits the text edits to sort
86+
* @return a new sorted list of text edits
87+
*/
88+
private static List<TextEdit> sortTextEdits(List<? extends TextEdit> edits) {
89+
// No need to sort if there's 0 or 1 edit
90+
if (edits.size() <= 1) {
91+
return new ArrayList<>(edits);
8092
}
81-
return diff;
82-
};
8393

84-
private static final Comparator<TextEdit> TEXT_EDITS_DESCENDING_COMPARATOR = (a, b) -> {
85-
int diff = b.getRange().getStart().getLine() - a.getRange().getStart().getLine();
86-
if (diff == 0) {
87-
return b.getRange().getStart().getCharacter() - a.getRange().getStart().getCharacter();
94+
// Create indexed entries to preserve original order for edits at the same position
95+
List<IndexedTextEdit> indexedEdits = new ArrayList<>(edits.size());
96+
for (int i = 0; i < edits.size(); i++) {
97+
indexedEdits.add(new IndexedTextEdit(i, edits.get(i)));
8898
}
89-
return diff;
90-
};
99+
100+
// Sort text edits by position, then by index when positions are equal.
101+
// The index preserves the original array order for same-position edits,
102+
// as required by the LSP spec.
103+
indexedEdits.sort(Comparator
104+
.comparingInt((IndexedTextEdit e) -> e.edit.getRange().getStart().getLine())
105+
.thenComparingInt(e -> e.edit.getRange().getStart().getCharacter())
106+
.thenComparingInt((IndexedTextEdit e) -> e.index));
107+
108+
// Extract sorted edits
109+
List<TextEdit> result = new ArrayList<>(indexedEdits.size());
110+
for (IndexedTextEdit indexed : indexedEdits) {
111+
result.add(indexed.edit);
112+
}
113+
return result;
114+
}
91115

92116
/**
93117
* Open the LSP location in an editor.
@@ -1163,6 +1187,11 @@ private static void doApplyEdits(@Nullable Editor editor,
11631187
@NotNull List<TextEdit> edits,
11641188
boolean saveDocument) {
11651189
// Create an owned copy to insulate against modification of the provided list while processing it
1190+
// We don't sort here because:
1191+
// 1. RangeMarkers capture positions from the original document (S1 in LSP spec)
1192+
// 2. All edits refer to the original document, not intermediate states
1193+
// 3. RangeMarkers automatically adjust as edits are applied
1194+
// 4. The original array order must be preserved for same-position edits
11661195
List<TextEdit> ownedEdits = new ArrayList<>(edits);
11671196

11681197
if (ownedEdits.isEmpty()) {
@@ -1317,13 +1346,9 @@ private static int getIncrement(int start, int end, int caret) {
13171346
*/
13181347
public static String applyEdits(@NotNull Document document,
13191348
@NotNull List<? extends TextEdit> edits) {
1320-
// Create an owned mutable copy since we're going to modify the list
1321-
List<TextEdit> mutableEdits = new ArrayList<>(edits);
1349+
// Sort text edits by position, preserving array order for same-position edits
1350+
List<TextEdit> mutableEdits = sortTextEdits(edits);
13221351

1323-
// Sort text edits
1324-
if (mutableEdits.size() > 1) {
1325-
mutableEdits.sort(TEXT_EDITS_ASCENDING_COMPARATOR);
1326-
}
13271352
String text = document.getText();
13281353
int lastModifiedOffset = 0;
13291354
List<String> spans = new ArrayList<>(mutableEdits.size() + 1);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
package com.redhat.devtools.lsp4ij;
12+
13+
import com.intellij.openapi.editor.Document;
14+
import com.intellij.openapi.editor.impl.DocumentImpl;
15+
import com.intellij.testFramework.fixtures.BasePlatformTestCase;
16+
import org.eclipse.lsp4j.Position;
17+
import org.eclipse.lsp4j.Range;
18+
import org.eclipse.lsp4j.TextEdit;
19+
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
23+
import static org.junit.Assert.assertEquals;
24+
25+
/**
26+
* Tests for {@link LSPIJUtils#applyEdits(Document, List)} with multiple text edits at the same position.
27+
* According to LSP spec: "if multiple inserts have the same position, the order in the array
28+
* defines the order in which the inserted strings appear in the resulting text."
29+
*
30+
* This method is used for formatting, where text edits must be sorted by position and
31+
* same-position edits must preserve array order.
32+
*/
33+
public class LSPIJUtils_applyEdits_SamePositionTest extends BasePlatformTestCase {
34+
35+
/**
36+
* Test that multiple insertions at the same position maintain array order.
37+
*/
38+
public void testMultipleInsertsAtSamePosition() {
39+
Document document = new DocumentImpl("world");
40+
41+
List<TextEdit> edits = new ArrayList<>();
42+
// Insert three strings at position 0:0 in order: "Hello ", ", ", "beautiful "
43+
// Expected result: "Hello , beautiful world" (in that exact order)
44+
edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "Hello "));
45+
edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), ", "));
46+
edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "beautiful "));
47+
48+
String result = LSPIJUtils.applyEdits(document, edits);
49+
assertEquals("Hello , beautiful world", result);
50+
}
51+
52+
/**
53+
* Test multiple insertions at the same position with mixed line/character positions.
54+
*/
55+
public void testMultipleInsertsAtSamePositionMidLine() {
56+
Document document = new DocumentImpl("start end");
57+
58+
List<TextEdit> edits = new ArrayList<>();
59+
// Insert three strings at position 0:6 (between "start " and "end")
60+
// In order: "one ", "two ", "three "
61+
edits.add(new TextEdit(new Range(new Position(0, 6), new Position(0, 6)), "one "));
62+
edits.add(new TextEdit(new Range(new Position(0, 6), new Position(0, 6)), "two "));
63+
edits.add(new TextEdit(new Range(new Position(0, 6), new Position(0, 6)), "three "));
64+
65+
String result = LSPIJUtils.applyEdits(document, edits);
66+
assertEquals("start one two three end", result);
67+
}
68+
69+
/**
70+
* Test that edits maintain order even when mixed with edits at different positions.
71+
*/
72+
public void testMixedPositionEditsWithSamePositionInserts() {
73+
Document document = new DocumentImpl("line1\nline2\nline3\n");
74+
75+
List<TextEdit> edits = new ArrayList<>();
76+
// Multiple edits:
77+
// - Two inserts at 0:0 (should maintain order: "A", "B")
78+
// - One insert at 1:0
79+
// - Two inserts at 2:0 (should maintain order: "X", "Y")
80+
edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "A"));
81+
edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "B"));
82+
edits.add(new TextEdit(new Range(new Position(1, 0), new Position(1, 0)), "C"));
83+
edits.add(new TextEdit(new Range(new Position(2, 0), new Position(2, 0)), "X"));
84+
edits.add(new TextEdit(new Range(new Position(2, 0), new Position(2, 0)), "Y"));
85+
86+
String result = LSPIJUtils.applyEdits(document, edits);
87+
assertEquals("ABline1\nCline2\nXYline3\n", result);
88+
}
89+
}

0 commit comments

Comments
 (0)