Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 43 additions & 18 deletions src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -73,21 +73,45 @@ public class LSPIJUtils {

private static final String ENCODED_HASH_SEPARATOR = "%23";

private static final Comparator<TextEdit> 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<TextEdit> sortTextEdits(List<? extends TextEdit> 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<TextEdit> 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<IndexedTextEdit> 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<TextEdit> result = new ArrayList<>(indexedEdits.size());
for (IndexedTextEdit indexed : indexedEdits) {
result.add(indexed.edit);
}
return result;
}

/**
* Open the LSP location in an editor.
Expand Down Expand Up @@ -1163,6 +1187,11 @@ private static void doApplyEdits(@Nullable Editor editor,
@NotNull List<TextEdit> 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<TextEdit> ownedEdits = new ArrayList<>(edits);

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

// Sort text edits
if (mutableEdits.size() > 1) {
mutableEdits.sort(TEXT_EDITS_ASCENDING_COMPARATOR);
}
String text = document.getText();
int lastModifiedOffset = 0;
List<String> spans = new ArrayList<>(mutableEdits.size() + 1);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TextEdit> 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<TextEdit> 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<TextEdit> 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);
}
}
Loading