Skip to content

Commit d693854

Browse files
wip
1 parent fbb0801 commit d693854

File tree

9 files changed

+633
-563
lines changed

9 files changed

+633
-563
lines changed

private/buf/buflsp/completion_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"testing"
2020

2121
"buf.build/go/standard/xslices"
22+
"github.com/bufbuild/buf/private/buf/buflsp"
2223
"github.com/stretchr/testify/assert"
2324
"github.com/stretchr/testify/require"
2425
"go.lsp.dev/protocol"
@@ -412,3 +413,102 @@ func TestCompletionOptions(t *testing.T) {
412413
})
413414
}
414415
}
416+
func TestCompletionAdditionalTextEdits(t *testing.T) {
417+
t.Parallel()
418+
419+
ctx := t.Context()
420+
421+
testProtoPath, err := filepath.Abs("testdata/completion/additional_edits_test.proto")
422+
require.NoError(t, err)
423+
424+
clientJSONConn, testURI := setupLSPServer(t, testProtoPath)
425+
426+
tests := []struct {
427+
name string
428+
line uint32
429+
character uint32
430+
triggerCompletion string // Text to search for in completions
431+
expectAdditionalEdit bool
432+
validateEdit func(t *testing.T, edit protocol.TextEdit)
433+
}{
434+
{
435+
name: "syntax_completion_adds_semicolon",
436+
line: 0,
437+
character: 10, // Inside the quotes: syntax = "|"
438+
triggerCompletion: "proto3",
439+
expectAdditionalEdit: true,
440+
validateEdit: func(t *testing.T, edit protocol.TextEdit) {
441+
// Should add ";" at end of line since the file is missing it
442+
assert.Equal(t, ";", edit.NewText, "should add semicolon")
443+
},
444+
},
445+
}
446+
447+
for _, tt := range tests {
448+
t.Run(tt.name, func(t *testing.T) {
449+
t.Parallel()
450+
451+
var completionList protocol.CompletionList
452+
_, err := clientJSONConn.Call(ctx, protocol.MethodTextDocumentCompletion, protocol.CompletionParams{
453+
TextDocumentPositionParams: protocol.TextDocumentPositionParams{
454+
TextDocument: protocol.TextDocumentIdentifier{
455+
URI: testURI,
456+
},
457+
Position: protocol.Position{
458+
Line: tt.line,
459+
Character: tt.character,
460+
},
461+
},
462+
}, &completionList)
463+
require.NoError(t, err)
464+
465+
// Find the completion item we're looking for
466+
var foundItem *protocol.CompletionItem
467+
for _, item := range completionList.Items {
468+
if item.Label == tt.triggerCompletion ||
469+
(item.TextEdit != nil && item.TextEdit.NewText == tt.triggerCompletion) {
470+
foundItem = &item
471+
break
472+
}
473+
}
474+
475+
require.NotNil(t, foundItem, "should find completion item %q", tt.triggerCompletion)
476+
477+
if tt.expectAdditionalEdit {
478+
require.NotEmpty(t, foundItem.AdditionalTextEdits, "should have additional text edits")
479+
480+
// Validate edits are minimal
481+
for i, edit := range foundItem.AdditionalTextEdits {
482+
// Additional edits should be small and targeted
483+
assert.LessOrEqual(t, len(edit.NewText), 100,
484+
"additional edit %d should be small (got %d chars)", i, len(edit.NewText))
485+
486+
// Validate specific edit properties if provided
487+
if tt.validateEdit != nil {
488+
tt.validateEdit(t, edit)
489+
}
490+
491+
// Additional edits should not overlap with the main edit
492+
if foundItem.TextEdit != nil {
493+
assert.False(t, buflsp.RangesOverlap(foundItem.TextEdit.Range, edit.Range),
494+
"additional edit %d should not overlap with main edit", i)
495+
}
496+
}
497+
498+
// Verify edits don't overlap with each other
499+
for i := 0; i < len(foundItem.AdditionalTextEdits); i++ {
500+
for j := i + 1; j < len(foundItem.AdditionalTextEdits); j++ {
501+
assert.False(t, buflsp.RangesOverlap(
502+
foundItem.AdditionalTextEdits[i].Range,
503+
foundItem.AdditionalTextEdits[j].Range),
504+
"additional edits %d and %d should not overlap", i, j)
505+
}
506+
}
507+
} else {
508+
assert.Empty(t, foundItem.AdditionalTextEdits, "should not have additional text edits")
509+
}
510+
})
511+
}
512+
}
513+
514+
// RangesOverlap returns true if two LSP ranges overlap.

private/buf/buflsp/document_link_test.go

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"path/filepath"
1919
"testing"
2020

21+
"github.com/bufbuild/buf/private/buf/buflsp"
2122
"github.com/stretchr/testify/assert"
2223
"github.com/stretchr/testify/require"
2324
"go.lsp.dev/protocol"
@@ -151,7 +152,7 @@ func assertNoOverlappingRanges(t *testing.T, links []protocol.DocumentLink) {
151152
// Check if ranges overlap
152153
assert.False(
153154
t,
154-
rangesOverlap(range1, range2),
155+
buflsp.RangesOverlap(range1, range2),
155156
"Document link ranges overlap:\nLink %d (target=%s): Line %d:%d to %d:%d\nLink %d (target=%s): Line %d:%d to %d:%d",
156157
i, links[i].Target,
157158
range1.Start.Line, range1.Start.Character,
@@ -165,14 +166,6 @@ func assertNoOverlappingRanges(t *testing.T, links []protocol.DocumentLink) {
165166
}
166167

167168
// rangesOverlap returns true if two ranges overlap.
168-
func rangesOverlap(r1, r2 protocol.Range) bool {
169-
// A range ends before another starts if r1.End <= r2.Start
170-
// Ranges don't overlap if one ends before the other starts
171-
if positionLessOrEqual(r1.End, r2.Start) || positionLessOrEqual(r2.End, r1.Start) {
172-
return false
173-
}
174-
return true
175-
}
176169

177170
// positionLessOrEqual returns true if pos1 <= pos2.
178171
func positionLessOrEqual(pos1, pos2 protocol.Position) bool {

private/buf/buflsp/format_test.go

Lines changed: 10 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ package buflsp_test
1616

1717
import (
1818
"context"
19-
"fmt"
2019
"os"
2120
"path/filepath"
2221
"strings"
2322
"testing"
2423

24+
"github.com/bufbuild/buf/private/buf/buflsp"
2525
"github.com/bufbuild/buf/private/buf/bufformat"
2626
"github.com/bufbuild/buf/private/pkg/storage"
2727
"github.com/bufbuild/buf/private/pkg/storage/storageos"
@@ -156,7 +156,7 @@ func TestFormatting(t *testing.T) {
156156
}
157157

158158
originalContent := mustReadFile(t, testProtoPath)
159-
result := applyTextEdits(originalContent, textEdits)
159+
result := buflsp.ApplyTextEdits(originalContent, textEdits)
160160
expectedFormatted := getExpectedFormattedContent(t, ctx, testProtoPath)
161161

162162
assert.Equal(t, expectedFormatted, result)
@@ -179,7 +179,7 @@ func TestFormattingIdempotency(t *testing.T) {
179179
// First format: get the formatted content
180180
firstEdits := callFormatting(t, ctx, testProtoPath)
181181
originalContent := mustReadFile(t, testProtoPath)
182-
formattedContent := applyTextEdits(originalContent, firstEdits)
182+
formattedContent := buflsp.ApplyTextEdits(originalContent, firstEdits)
183183

184184
// Write formatted content to a temp directory with necessary dependencies
185185
tmpDir := setupTempFormatDir(t, testProtoPath, protoFile, formattedContent)
@@ -244,7 +244,7 @@ func assertEditsAreMinimal(t *testing.T, original, expected string, edits []prot
244244
totalEditSize, fullReplaceSize)
245245

246246
// Verify edits actually transform original to expected
247-
result := applyTextEdits(original, edits)
247+
result := buflsp.ApplyTextEdits(original, edits)
248248
assert.Equal(t, expected, result, "applying edits should produce expected formatted text")
249249

250250
// Verify no edit is entirely unchanged
@@ -257,11 +257,11 @@ func assertEditsDoNotOverlap(t *testing.T, edits []protocol.TextEdit) {
257257

258258
for i := 0; i < len(edits); i++ {
259259
for j := i + 1; j < len(edits); j++ {
260-
assert.Falsef(t, rangesOverlapFormat(edits[i].Range, edits[j].Range),
260+
assert.Falsef(t, buflsp.RangesOverlap(edits[i].Range, edits[j].Range),
261261
"edits %d and %d overlap: %s and %s",
262262
i, j,
263-
formatRange(edits[i].Range),
264-
formatRange(edits[j].Range))
263+
buflsp.FormatRange(edits[i].Range),
264+
buflsp.FormatRange(edits[j].Range))
265265
}
266266
}
267267
}
@@ -288,112 +288,10 @@ func assertNoRedundantEdits(t *testing.T, original string, edits []protocol.Text
288288

289289
assert.NotEqualf(t, edit.NewText, replacedText,
290290
"edit %d replaces text with identical text (not minimal): range %s",
291-
i, formatRange(edit.Range))
291+
i, buflsp.FormatRange(edit.Range))
292292
}
293293
}
294294

295-
func applyTextEdits(text string, edits []protocol.TextEdit) string {
296-
lines := strings.Split(text, "\n")
297-
// Apply edits in reverse order to maintain line/character positions
298-
for i := len(edits) - 1; i >= 0; i-- {
299-
edit := edits[i]
300-
startLine := int(edit.Range.Start.Line)
301-
startChar := int(edit.Range.Start.Character)
302-
endLine := int(edit.Range.End.Line)
303-
endChar := int(edit.Range.End.Character)
304-
305-
if startLine < 0 || startLine >= len(lines) {
306-
continue
307-
}
308-
if endLine < 0 || endLine > len(lines) {
309-
endLine = len(lines)
310-
}
311-
312-
// LSP TextEdit semantics: Range.End is exclusive at character level
313-
// Get the prefix (part of start line before the edit)
314-
prefix := ""
315-
if startLine < len(lines) && startChar <= len(lines[startLine]) {
316-
prefix = lines[startLine][:startChar]
317-
}
318-
319-
// Get the suffix (part of end line after the edit)
320-
// If endChar is 0, we're at the start of endLine, so no suffix from that line
321-
suffix := ""
322-
if endChar > 0 && endLine < len(lines) {
323-
if endChar <= len(lines[endLine]) {
324-
suffix = lines[endLine][endChar:]
325-
}
326-
} else if startLine == endLine && endChar == 0 {
327-
// Pure insertion at the beginning of a line - need the whole line as suffix
328-
if endLine < len(lines) {
329-
suffix = lines[endLine]
330-
}
331-
}
332-
333-
// Determine which lines to delete
334-
// For a pure insertion (start == end), the line gets incorporated into replacement via suffix
335-
// So we need to delete it from the original lines array
336-
// Otherwise, we delete from startLine to endLine (inclusive or exclusive depending on endChar)
337-
deleteEndLine := endLine
338-
if startLine == endLine && startChar == endChar {
339-
// Pure insertion - the line is preserved in suffix, so delete it from lines array
340-
deleteEndLine = startLine
341-
} else if endChar == 0 && endLine > startLine {
342-
// Range ends at the beginning of endLine, so don't include endLine in deletion
343-
deleteEndLine = endLine - 1
344-
}
345-
346-
// Split the new text into lines
347-
newText := edit.NewText
348-
var newLines []string
349-
endsWithNewline := false
350-
if newText != "" {
351-
newLines = strings.Split(newText, "\n")
352-
// If newText ends with \n, split gives us a trailing empty string.
353-
// This indicates that subsequent content should be on a new line.
354-
endsWithNewline = strings.HasSuffix(newText, "\n")
355-
if endsWithNewline && len(newLines) > 0 && newLines[len(newLines)-1] == "" {
356-
newLines = newLines[:len(newLines)-1]
357-
}
358-
}
359-
360-
// Build the replacement
361-
var replacement []string
362-
if len(newLines) == 0 {
363-
// No new content
364-
combined := prefix + suffix
365-
// Only create a line if there's actually content or we're doing a mid-line edit
366-
if combined != "" || (startChar > 0 || endChar > 0) {
367-
replacement = []string{combined}
368-
} else {
369-
// Deleting full lines with no replacement
370-
replacement = []string{}
371-
}
372-
} else {
373-
// Add prefix to first line
374-
replacement = make([]string, len(newLines))
375-
copy(replacement, newLines)
376-
replacement[0] = prefix + replacement[0]
377-
// Add suffix: if newText ended with \n, suffix goes on a new line
378-
// Otherwise, append it to the last line
379-
if suffix != "" {
380-
if endsWithNewline {
381-
replacement = append(replacement, suffix)
382-
} else {
383-
replacement[len(replacement)-1] = replacement[len(replacement)-1] + suffix
384-
}
385-
} else if startLine == endLine && startChar == endChar && endsWithNewline {
386-
// Pure insertion at start of line where newText ends with newline
387-
// Even if the original line is blank (suffix == ""), we should preserve it
388-
replacement = append(replacement, "")
389-
}
390-
}
391-
392-
// Replace lines[startLine:deleteEndLine+1] with replacement
393-
lines = append(lines[:startLine], append(replacement, lines[deleteEndLine+1:]...)...)
394-
}
395-
return strings.Join(lines, "\n")
396-
}
397295

398296
func TestApplyTextEdits(t *testing.T) {
399297
t.Parallel()
@@ -460,7 +358,7 @@ func TestApplyTextEdits(t *testing.T) {
460358

461359
for _, tt := range tests {
462360
t.Run(tt.name, func(t *testing.T) {
463-
result := applyTextEdits(tt.input, tt.edits)
361+
result := buflsp.ApplyTextEdits(tt.input, tt.edits)
464362
assert.Equal(t, tt.expected, result)
465363
})
466364
}
@@ -475,7 +373,7 @@ func TestApplyTextEditsDebug(t *testing.T) {
475373

476374
edits := callFormatting(t, ctx, testProtoPath)
477375
originalContent := mustReadFile(t, testProtoPath)
478-
result := applyTextEdits(originalContent, edits)
376+
result := buflsp.ApplyTextEdits(originalContent, edits)
479377
expected := getExpectedFormattedContent(t, ctx, testProtoPath)
480378

481379
assert.Equal(t, expected, result, "Result doesn't match expected")
@@ -554,24 +452,3 @@ func setupTempFormatDir(t *testing.T, originalPath, protoFile, formattedContent
554452
return tmpDir
555453
}
556454

557-
// rangesOverlap returns true if two LSP ranges overlap.
558-
// Two ranges overlap if one starts before the other ends.
559-
func rangesOverlapFormat(r1, r2 protocol.Range) bool {
560-
// Compare positions: r1.Start < r2.End && r2.Start < r1.End
561-
return positionLess(r1.Start, r2.End) && positionLess(r2.Start, r1.End)
562-
}
563-
564-
// positionLess returns true if p1 is before p2 in the document.
565-
func positionLess(p1, p2 protocol.Position) bool {
566-
if p1.Line != p2.Line {
567-
return p1.Line < p2.Line
568-
}
569-
return p1.Character < p2.Character
570-
}
571-
572-
// formatRange returns a human-readable string representation of an LSP range.
573-
func formatRange(r protocol.Range) string {
574-
return fmt.Sprintf("[%d:%d-%d:%d]",
575-
r.Start.Line, r.Start.Character,
576-
r.End.Line, r.End.Character)
577-
}

0 commit comments

Comments
 (0)