Skip to content

Commit 2d2a07e

Browse files
authored
LSP add support for type completions (#4112)
1 parent 82a0803 commit 2d2a07e

File tree

1 file changed

+160
-22
lines changed

1 file changed

+160
-22
lines changed

private/buf/buflsp/completion.go

Lines changed: 160 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/bufbuild/buf/private/pkg/normalpath"
2828
"github.com/bufbuild/protocompile/experimental/ast"
2929
"github.com/bufbuild/protocompile/experimental/ast/syntax"
30+
"github.com/bufbuild/protocompile/experimental/ir"
3031
"github.com/bufbuild/protocompile/experimental/report"
3132
"github.com/bufbuild/protocompile/experimental/seq"
3233
"github.com/bufbuild/protocompile/experimental/token"
@@ -197,7 +198,7 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA
197198
case ast.DefKindEnum:
198199
return nil
199200
case ast.DefKindField:
200-
return completionItemsForField(ctx, file, declPath, def.AsField(), position)
201+
return completionItemsForField(ctx, file, declPath, def, position)
201202
case ast.DefKindInvalid:
202203
return completionItemsForKeyword(ctx, file, declPath, def, position)
203204
default:
@@ -210,11 +211,13 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA
210211
func completionItemsForKeyword(ctx context.Context, file *file, declPath []ast.DeclAny, def ast.DeclDef, position protocol.Position) []protocol.CompletionItem {
211212
positionLocation := file.file.InverseLocation(int(position.Line)+1, int(position.Character)+1, positionalEncoding)
212213
offset := positionLocation.Offset
213-
214214
span := def.Span()
215215

216216
// Check if at newline or end of span. Keywords are restricted to the first identifier.
217217
if !isNewlineOrEndOfSpan(span, offset) {
218+
if len(declPath) > 1 {
219+
return completionItemsForField(ctx, file, declPath, def, position)
220+
}
218221
file.lsp.logger.Debug("completion: keyword skip on span bounds", slog.String("span", span.Text()))
219222
return nil
220223
}
@@ -247,7 +250,7 @@ func completionItemsForKeyword(ctx context.Context, file *file, declPath []ast.D
247250
switch parentDef.Classify() {
248251
case ast.DefKindMessage:
249252
isProto2 := isProto2(file)
250-
items = joinCompletionItems(
253+
items = joinSequences(
251254
keywordToCompletionItem(
252255
messageLevelKeywords(isProto2),
253256
protocol.CompletionItemKindKeyword,
@@ -266,7 +269,11 @@ func completionItemsForKeyword(ctx context.Context, file *file, declPath []ast.D
266269
tokenSpan,
267270
offset,
268271
),
269-
// TODO: add custom types.
272+
typeReferencesToCompletionItems(
273+
file,
274+
tokenSpan,
275+
offset,
276+
),
270277
)
271278
case ast.DefKindService:
272279
items = keywordToCompletionItem(
@@ -289,10 +296,10 @@ func completionItemsForKeyword(ctx context.Context, file *file, declPath []ast.D
289296
}
290297

291298
// completionItemsForField returns completion items for a field.
292-
func completionItemsForField(ctx context.Context, file *file, declPath []ast.DeclAny, defField ast.DefField, position protocol.Position) []protocol.CompletionItem {
299+
func completionItemsForField(ctx context.Context, file *file, declPath []ast.DeclAny, def ast.DeclDef, position protocol.Position) []protocol.CompletionItem {
293300
if len(declPath) == 1 {
294301
file.lsp.logger.DebugContext(ctx, "completion: field top level, going to keywords")
295-
return completionItemsForKeyword(ctx, file, declPath, defField.Decl, position)
302+
return completionItemsForKeyword(ctx, file, declPath, def, position)
296303
}
297304

298305
parent := declPath[len(declPath)-2]
@@ -308,33 +315,89 @@ func completionItemsForField(ctx context.Context, file *file, declPath []ast.Dec
308315
offset := positionLocation.Offset
309316

310317
// If on a newline, before the current field or at the end of the span return keywords.
311-
if isNewlineOrEndOfSpan(defField.Span(), offset) {
318+
if isNewlineOrEndOfSpan(def.Span(), offset) {
312319
file.lsp.logger.DebugContext(ctx, "completion: field on newline, return keywords")
313-
return completionItemsForKeyword(ctx, file, declPath, defField.Decl, position)
320+
return completionItemsForKeyword(ctx, file, declPath, def, position)
314321
}
315322

316-
typeSpan := defField.Type.Span()
317-
start, end := typeSpan.Start, typeSpan.End
318-
319-
if start > offset || offset > end {
323+
typeSpan := def.Type().Span()
324+
if offsetInSpan(typeSpan, offset) {
320325
file.lsp.logger.DebugContext(
321326
ctx, "completion: field outside definition",
322327
slog.String("kind", parent.Kind().String()),
323328
)
324329
return nil
325330
}
326331

327-
def := parent.AsDef()
328-
switch def.Classify() {
332+
// Resolve the token the cursor is under. We don't use the ast.FieldStruct as this doesn't
333+
// work with invalid path types. Instead resolve the token from the stream.
334+
tokenSpan := extractAroundToken(file, offset)
335+
tokenPrefix, tokenSuffix := splitSpan(tokenSpan, offset)
336+
typePrefix, typeSuffix := splitSpan(typeSpan, offset)
337+
prefixCount := 0
338+
for range strings.FieldsSeq(strings.TrimSuffix(typePrefix, tokenPrefix)) {
339+
prefixCount++
340+
}
341+
suffixCount := 0
342+
for range strings.FieldsSeq(strings.TrimPrefix(typeSuffix, tokenSuffix)) {
343+
suffixCount++
344+
}
345+
// Limit completions based on the following heuristic:
346+
// - Show modifiers for the first two types
347+
// - Only show types on the final type
348+
showModifiers := prefixCount <= 2
349+
showTypes := suffixCount == 0
350+
351+
file.lsp.logger.DebugContext(
352+
ctx, "completion: got types",
353+
slog.String("kind", parent.Kind().String()),
354+
slog.String("type", typeSpan.Text()),
355+
slog.String("type_kind", def.Type().Kind().String()),
356+
slog.Int("start", typeSpan.Start),
357+
slog.Int("end", typeSpan.End),
358+
slog.Bool("show_modifiers", showModifiers),
359+
slog.Bool("show_types", showTypes),
360+
slog.String("token", tokenSpan.Text()),
361+
)
362+
363+
var items iter.Seq[protocol.CompletionItem]
364+
parentDef := parent.AsDef()
365+
switch parentDef.Classify() {
329366
case ast.DefKindMessage:
330-
return nil
331-
case ast.DefKindService:
332-
return nil
333-
case ast.DefKindEnum:
334-
return nil
367+
var iters []iter.Seq[protocol.CompletionItem]
368+
if showModifiers {
369+
iters = append(iters,
370+
keywordToCompletionItem(
371+
messageLevelFieldKeywords(),
372+
protocol.CompletionItemKindKeyword,
373+
tokenSpan,
374+
offset,
375+
),
376+
)
377+
}
378+
if showTypes {
379+
iters = append(iters,
380+
keywordToCompletionItem(
381+
predeclaredTypeKeywords(),
382+
protocol.CompletionItemKindClass,
383+
tokenSpan,
384+
offset,
385+
),
386+
typeReferencesToCompletionItems(
387+
file,
388+
tokenSpan,
389+
offset,
390+
),
391+
)
392+
}
393+
if len(iters) == 0 {
394+
return nil
395+
}
396+
items = joinSequences(iters...)
335397
default:
336398
return nil
337399
}
400+
return slices.Collect(items)
338401
}
339402

340403
// completionItemsForImport returns completion items for import declarations.
@@ -504,9 +567,80 @@ func keywordToCompletionItem(
504567
}
505568
}
506569

507-
// joinCompletionItems returns a sequence of sequences.
508-
func joinCompletionItems(itemIters ...iter.Seq[protocol.CompletionItem]) iter.Seq[protocol.CompletionItem] {
570+
// typeReferencesToCompletionItems returns completion items for user-defined types (messages, enums, etc).
571+
func typeReferencesToCompletionItems(
572+
current *file,
573+
span report.Span,
574+
offset int,
575+
) iter.Seq[protocol.CompletionItem] {
576+
fileSymbolTypesIter := func(yield func(*file, *symbol) bool) {
577+
for _, imported := range current.importToFile {
578+
for _, symbol := range imported.referenceableSymbols {
579+
if !yield(imported, symbol) {
580+
return
581+
}
582+
}
583+
}
584+
}
509585
return func(yield func(protocol.CompletionItem) bool) {
586+
editRange := reportSpanToProtocolRange(span)
587+
prefix, suffix := splitSpan(span, offset)
588+
for file, symbol := range fileSymbolTypesIter {
589+
if !symbol.ir.Kind().IsType() {
590+
continue
591+
}
592+
var (
593+
label string
594+
kind protocol.CompletionItemKind
595+
)
596+
if file.ir.Package() == current.ir.Package() {
597+
label = symbol.ir.FullName().Name()
598+
} else {
599+
label = string(symbol.ir.FullName())
600+
}
601+
if !strings.HasPrefix(label, prefix) || !strings.HasSuffix(label, suffix) {
602+
continue
603+
}
604+
switch symbol.ir.Kind() {
605+
case ir.SymbolKindMessage:
606+
kind = protocol.CompletionItemKindStruct
607+
case ir.SymbolKindEnum:
608+
kind = protocol.CompletionItemKindEnum
609+
case ir.SymbolKindService:
610+
kind = protocol.CompletionItemKindInterface
611+
case ir.SymbolKindScalar:
612+
kind = protocol.CompletionItemKindClass
613+
case ir.SymbolKindPackage:
614+
kind = protocol.CompletionItemKindModule
615+
case ir.SymbolKindField, ir.SymbolKindEnumValue, ir.SymbolKindExtension, ir.SymbolKindOneof, ir.SymbolKindMethod:
616+
// These should be skipped by IsType() filter.
617+
}
618+
if kind == 0 {
619+
continue // Unsupported kind, skip it.
620+
}
621+
var isDeprecated bool
622+
if _, ok := symbol.ir.Deprecated().AsBool(); ok {
623+
isDeprecated = true
624+
}
625+
if !yield(protocol.CompletionItem{
626+
Label: label,
627+
Kind: kind,
628+
TextEdit: &protocol.TextEdit{
629+
Range: editRange,
630+
NewText: label,
631+
},
632+
Deprecated: isDeprecated,
633+
// TODO: If this type's file is not currently imported add an additional edit.
634+
}) {
635+
break
636+
}
637+
}
638+
}
639+
}
640+
641+
// joinSequences returns a sequence of sequences.
642+
func joinSequences[T any](itemIters ...iter.Seq[T]) iter.Seq[T] {
643+
return func(yield func(T) bool) {
510644
for _, items := range itemIters {
511645
for item := range items {
512646
if !yield(item) {
@@ -614,14 +748,18 @@ func extractAroundToken(file *file, offset int) report.Span {
614748
}
615749

616750
func splitSpan(span report.Span, offset int) (prefix string, suffix string) {
617-
if span.Start > offset || offset > span.End {
751+
if !offsetInSpan(span, offset) {
618752
return "", ""
619753
}
620754
index := offset - span.Start
621755
text := span.Text()
622756
return text[:index], text[index:]
623757
}
624758

759+
func offsetInSpan(span report.Span, offset int) bool {
760+
return span.Start > offset || offset > span.End
761+
}
762+
625763
// isNewlineOrEndOfSpan returns true if this offset is separated be a newline or at the end of the span.
626764
// This most likely means we are at the start of a new declaration.
627765
func isNewlineOrEndOfSpan(span report.Span, offset int) bool {

0 commit comments

Comments
 (0)