diff --git a/lsp/mapper/apply_test.go b/lsp/mapper/apply_test.go new file mode 100644 index 0000000..d6b0fbe --- /dev/null +++ b/lsp/mapper/apply_test.go @@ -0,0 +1,191 @@ +package mapper + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.lsp.dev/protocol" +) + +// pos builds an LSP position on line 0. +func pos(char uint32) protocol.Position { + return protocol.Position{Line: 0, Character: char} +} + +func TestApplyEdits(t *testing.T) { + tests := []struct { + name string + content string + edits []protocol.TextEdit + want string + }{ + { + name: "single replacement", + content: "hello world", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: pos(6), End: pos(11)}, NewText: "there"}, + }, + want: "hello there", + }, + { + name: "insertion at empty range", + content: "hello world", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: pos(5), End: pos(5)}, NewText: ","}, + }, + want: "hello, world", + }, + { + name: "deletion with empty text", + content: "hello world", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: pos(5), End: pos(11)}, NewText: ""}, + }, + want: "hello", + }, + { + name: "unsorted edits apply correctly", + content: "abcdef", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: pos(0), End: pos(1)}, NewText: "X"}, + {Range: protocol.Range{Start: pos(4), End: pos(5)}, NewText: "Y"}, + }, + want: "XbcdYf", + }, + { + name: "edit at document start and end", + content: "abc", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: pos(0), End: pos(0)}, NewText: "<"}, + {Range: protocol.Range{Start: pos(3), End: pos(3)}, NewText: ">"}, + }, + want: "", + }, + { + name: "replace whole document", + content: "abc", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: pos(0), End: pos(3)}, NewText: "xyz"}, + }, + want: "xyz", + }, + { + name: "no edits is identity", + content: "untouched", + edits: nil, + want: "untouched", + }, + { + name: "utf16 surrogate pair counts as two units", + // a + emoji(4 bytes, 2 units) + b: b sits at char 3. + content: "a😀b", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: pos(3), End: pos(4)}, NewText: "B"}, + }, + want: "a😀B", + }, + { + name: "utf16 cjk counts as one unit", + // a + 日(3 bytes, 1 unit) + b: b sits at char 2. + content: "a日b", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: pos(2), End: pos(3)}, NewText: "B"}, + }, + want: "a日B", + }, + { + name: "utf16 edit spanning wide runes", + content: "😀x日y", + edits: []protocol.TextEdit{ + // chars 0..4 span emoji(2) + x(1) + 日(1). + {Range: protocol.Range{Start: pos(0), End: pos(4)}, NewText: "ok"}, + }, + want: "oky", + }, + { + name: "multiline edit", + content: "one\ntwo\nthree\n", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: protocol.Position{Line: 1, Character: 0}, End: protocol.Position{Line: 2, Character: 5}}, NewText: "TWO"}, + }, + want: "one\nTWO\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewMapper([]byte(tt.content)).ApplyEdits(tt.edits) + require.NoError(t, err) + assert.Equal(t, tt.want, string(got)) + }) + } +} + +func TestApplyEditsErrors(t *testing.T) { + tests := []struct { + name string + content string + edits []protocol.TextEdit + }{ + { + name: "end past document", + content: "abc", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: pos(0), End: pos(4)}, NewText: "x"}, + }, + }, + { + name: "start past document", + content: "abc", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: pos(9), End: pos(9)}, NewText: "x"}, + }, + }, + { + name: "start after end", + content: "abc", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: pos(2), End: pos(1)}, NewText: "x"}, + }, + }, + { + name: "line past document", + content: "abc", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: protocol.Position{Line: 5, Character: 0}, End: protocol.Position{Line: 5, Character: 1}}, NewText: "x"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewMapper([]byte(tt.content)).ApplyEdits(tt.edits) + assert.Error(t, err) + }) + } +} + +// TestApplyEditsRoundTrip pins the property the format code actions rely +// on: applying the edits a formatter produced reproduces the formatter's +// whole-document output. +func TestApplyEditsRoundTrip(t *testing.T) { + content := []byte("struct A { 1: i32 a }\n\nstruct B {\n2: i32 b\n}\n") + want := []byte("struct A { 1: i32 a }\n\nstruct B { 2: i32 b }\n") + + m := NewMapper(content) + + edits := []protocol.TextEdit{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 2, Character: 0}, + End: protocol.Position{Line: 5, Character: 0}, + }, + NewText: "struct B { 2: i32 b }\n", + }, + } + + got, err := m.ApplyEdits(edits) + require.NoError(t, err) + assert.Equal(t, string(want), string(got)) +}