fix(lsp): treat adjacent ranges as non-overlapping per LSP spec (#2322)

huaiyuWangh created

Fix rangesOverlap() to treat LSP ranges as half-open intervals [start, end)
per the specification. Adjacent edits where one range ends where another
begins are no longer incorrectly rejected as overlapping.

Change summary

internal/lsp/util/edit.go      |  8 +++-
internal/lsp/util/edit_test.go | 68 ++++++++++++++++++++++++++++++++++++
2 files changed, 74 insertions(+), 2 deletions(-)

Detailed changes

internal/lsp/util/edit.go 🔗

@@ -247,14 +247,18 @@ func ApplyWorkspaceEdit(edit protocol.WorkspaceEdit) error {
 	return nil
 }
 
+// rangesOverlap checks if two LSP ranges overlap.
+// Per the LSP specification, ranges are half-open intervals [start, end),
+// so adjacent ranges where one's end equals another's start do NOT overlap.
+// See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#range
 func rangesOverlap(r1, r2 protocol.Range) bool {
 	if r1.Start.Line > r2.End.Line || r2.Start.Line > r1.End.Line {
 		return false
 	}
-	if r1.Start.Line == r2.End.Line && r1.Start.Character > r2.End.Character {
+	if r1.Start.Line == r2.End.Line && r1.Start.Character >= r2.End.Character {
 		return false
 	}
-	if r2.Start.Line == r1.End.Line && r2.Start.Character > r1.End.Character {
+	if r2.Start.Line == r1.End.Line && r2.Start.Character >= r1.End.Character {
 		return false
 	}
 	return true

internal/lsp/util/edit_test.go 🔗

@@ -0,0 +1,68 @@
+package util
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+)
+
+func TestRangesOverlap(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		r1   protocol.Range
+		r2   protocol.Range
+		want bool
+	}{
+		{
+			name: "adjacent ranges do not overlap",
+			r1: protocol.Range{
+				Start: protocol.Position{Line: 0, Character: 0},
+				End:   protocol.Position{Line: 0, Character: 5},
+			},
+			r2: protocol.Range{
+				Start: protocol.Position{Line: 0, Character: 5},
+				End:   protocol.Position{Line: 0, Character: 10},
+			},
+			want: false,
+		},
+		{
+			name: "overlapping ranges",
+			r1: protocol.Range{
+				Start: protocol.Position{Line: 0, Character: 0},
+				End:   protocol.Position{Line: 0, Character: 8},
+			},
+			r2: protocol.Range{
+				Start: protocol.Position{Line: 0, Character: 5},
+				End:   protocol.Position{Line: 0, Character: 10},
+			},
+			want: true,
+		},
+		{
+			name: "non-overlapping with gap",
+			r1: protocol.Range{
+				Start: protocol.Position{Line: 0, Character: 0},
+				End:   protocol.Position{Line: 0, Character: 3},
+			},
+			r2: protocol.Range{
+				Start: protocol.Position{Line: 0, Character: 7},
+				End:   protocol.Position{Line: 0, Character: 10},
+			},
+			want: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			got := rangesOverlap(tt.r1, tt.r2)
+			require.Equal(t, tt.want, got, "rangesOverlap(r1, r2)")
+			// Overlap should be symmetric
+			got2 := rangesOverlap(tt.r2, tt.r1)
+			require.Equal(t, tt.want, got2, "rangesOverlap(r2, r1) symmetry")
+		})
+	}
+}