Skip to content

Commit 18712c7

Browse files
authored
LSP add autocompletion for basic keywords, syntax, package and imports (#4067)
1 parent 0a61e1b commit 18712c7

File tree

5 files changed

+348
-4
lines changed

5 files changed

+348
-4
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ require (
1616
buf.build/go/standard v0.1.0
1717
connectrpc.com/connect v1.19.1
1818
connectrpc.com/otelconnect v0.8.0
19-
github.com/bufbuild/protocompile v0.14.2-0.20251016020322-1e423fd4469c
19+
github.com/bufbuild/protocompile v0.14.2-0.20251017100351-4264d1ccf8d2
2020
github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1
2121
github.com/docker/docker v28.5.1+incompatible
2222
github.com/go-chi/chi/v5 v5.2.3

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW
4040
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
4141
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
4242
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
43-
github.com/bufbuild/protocompile v0.14.2-0.20251016020322-1e423fd4469c h1:tUqu5Z1NlNlyZvd9DhGeoTM9v2FPR90BRaz7kqf+Cc4=
44-
github.com/bufbuild/protocompile v0.14.2-0.20251016020322-1e423fd4469c/go.mod h1:HKN246DRQwavs64sr2xYmSL+RFOFxmLti+WGCZ2jh9U=
43+
github.com/bufbuild/protocompile v0.14.2-0.20251017100351-4264d1ccf8d2 h1:7z/G5WYy7lwnK8WmRywHxr3kKGCU3zHEjz3mTykycA8=
44+
github.com/bufbuild/protocompile v0.14.2-0.20251017100351-4264d1ccf8d2/go.mod h1:HKN246DRQwavs64sr2xYmSL+RFOFxmLti+WGCZ2jh9U=
4545
github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 h1:V1xulAoqLqVg44rY97xOR+mQpD2N+GzhMHVwJ030WEU=
4646
github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ=
4747
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=

private/buf/buflsp/completion.go

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
// Copyright 2020-2025 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// This file implements code completion support for the LSP.
16+
17+
package buflsp
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"iter"
23+
"log/slog"
24+
"slices"
25+
"strings"
26+
27+
"github.com/bufbuild/buf/private/pkg/normalpath"
28+
"github.com/bufbuild/protocompile/experimental/ast"
29+
"github.com/bufbuild/protocompile/experimental/ast/syntax"
30+
"github.com/bufbuild/protocompile/experimental/seq"
31+
"github.com/bufbuild/protocompile/experimental/token/keyword"
32+
"go.lsp.dev/protocol"
33+
)
34+
35+
// getCompletionItems returns completion items for the given position in the file.
36+
//
37+
// This function is called by the Completion handler in server.go.
38+
func getCompletionItems(
39+
ctx context.Context,
40+
file *file,
41+
position protocol.Position,
42+
) []protocol.CompletionItem {
43+
if file.ir.AST().IsZero() {
44+
file.lsp.logger.DebugContext(
45+
ctx,
46+
"no AST found for completion",
47+
slog.String("file", file.uri.Filename()),
48+
)
49+
return nil
50+
}
51+
52+
declPath := getDeclForPosition(file.ir.AST().DeclBody, position)
53+
if len(declPath) > 0 {
54+
decl := declPath[len(declPath)-1]
55+
file.lsp.logger.DebugContext(
56+
ctx,
57+
"completion: found declaration",
58+
slog.String("decl_kind", decl.Kind().String()),
59+
slog.Any("decl_span_start", decl.Span().StartLoc()),
60+
slog.Any("decl_span_end", decl.Span().EndLoc()),
61+
slog.Int("path_depth", len(declPath)),
62+
)
63+
} else {
64+
file.lsp.logger.DebugContext(ctx, "completion: no declaration found at position")
65+
return nil
66+
}
67+
68+
// Return context-aware completions based on the declaration type.
69+
return completionItemsForDeclPath(ctx, file, declPath, position)
70+
}
71+
72+
// completionItemsForDeclPath returns completion items based on the AST declaration path at the cursor.
73+
// The declPath is a slice from parent to smallest declaration, where [0] is the top-level and [len-1] is the innermost.
74+
func completionItemsForDeclPath(ctx context.Context, file *file, declPath []ast.DeclAny, position protocol.Position) []protocol.CompletionItem {
75+
if len(declPath) == 0 {
76+
return nil
77+
}
78+
79+
// Get the innermost (smallest) declaration.
80+
decl := declPath[len(declPath)-1]
81+
file.lsp.logger.DebugContext(
82+
ctx,
83+
"completion: processing declaration",
84+
slog.String("kind", decl.Kind().String()),
85+
)
86+
87+
// Return context-specific completions based on declaration type.
88+
switch decl.Kind() {
89+
case ast.DeclKindInvalid:
90+
file.lsp.logger.DebugContext(ctx, "completion: ignoring invalid declaration")
91+
return nil
92+
case ast.DeclKindDef:
93+
return completionItemsForDef(ctx, file, declPath, decl.AsDef(), position)
94+
case ast.DeclKindSyntax:
95+
return completionItemsForSyntax(ctx, file, decl.AsSyntax())
96+
case ast.DeclKindPackage:
97+
return completionItemsForPackage(ctx, file, decl.AsPackage())
98+
case ast.DeclKindImport:
99+
return completionItemsForImport(ctx, file)
100+
default:
101+
file.lsp.logger.DebugContext(ctx, "completion: unknown declaration type", slog.String("kind", decl.Kind().String()))
102+
return nil
103+
}
104+
}
105+
106+
// completionItemsForSyntax returns the completion items for the files syntax.
107+
func completionItemsForSyntax(ctx context.Context, file *file, syntaxDecl ast.DeclSyntax) []protocol.CompletionItem {
108+
file.lsp.logger.DebugContext(ctx, "completion: syntax declaration", slog.Bool("is_edition", syntaxDecl.IsEdition()))
109+
110+
var prefix string
111+
if syntaxDecl.Equals().IsZero() {
112+
prefix += "= "
113+
}
114+
var syntaxes iter.Seq[syntax.Syntax]
115+
if syntaxDecl.IsEdition() {
116+
syntaxes = syntax.Editions()
117+
} else {
118+
syntaxes = func(yield func(syntax.Syntax) bool) {
119+
_ = yield(syntax.Proto2) &&
120+
yield(syntax.Proto3)
121+
}
122+
}
123+
var items []protocol.CompletionItem
124+
for syntax := range syntaxes {
125+
items = append(items, protocol.CompletionItem{
126+
Label: fmt.Sprintf("%s%q;", prefix, syntax),
127+
Kind: protocol.CompletionItemKindValue,
128+
})
129+
}
130+
return items
131+
}
132+
133+
// completionItemsForPackage returns the completion items for the package name.
134+
//
135+
// Suggest the package name based on the filepath.
136+
func completionItemsForPackage(ctx context.Context, file *file, syntaxPackage ast.DeclPackage) []protocol.CompletionItem {
137+
components := normalpath.Components(file.objectInfo.Path())
138+
suggested := components[:len(components)-1] // Strip the filename.
139+
if len(suggested) == 0 {
140+
file.lsp.logger.DebugContext(ctx, "completion: package at root, no suggestions")
141+
return nil // File is at root, return no suggestions.
142+
}
143+
file.lsp.logger.DebugContext(ctx, "completion: package suggestion", slog.String("package", strings.Join(suggested, ".")))
144+
return []protocol.CompletionItem{{
145+
Label: strings.Join(suggested, ".") + ";",
146+
Kind: protocol.CompletionItemKindSnippet,
147+
}}
148+
}
149+
150+
// completionItemsForDef returns completion items for definition declarations (message, enum, service, etc.).
151+
func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclAny, def ast.DeclDef, position protocol.Position) []protocol.CompletionItem {
152+
file.lsp.logger.DebugContext(
153+
ctx,
154+
"completion: definition",
155+
slog.String("type", def.Type().Span().String()),
156+
slog.String("name", def.Name().Span().String()),
157+
slog.String("def_kind", def.Classify().String()),
158+
slog.Int("path_depth", len(declPath)),
159+
slog.Any("decl_span_start", def.Span().StartLoc()),
160+
slog.Any("decl_span_end", def.Span().EndLoc()),
161+
)
162+
163+
// Check if cursor is in the name of the definition, if so ignore.
164+
if !def.Name().IsZero() {
165+
nameSpan := def.Name().Span()
166+
within := reportSpanToProtocolRange(nameSpan)
167+
if positionInRange(position, within) == 0 {
168+
file.lsp.logger.DebugContext(
169+
ctx,
170+
"completion: ignoring definition name",
171+
slog.String("def_kind", def.Classify().String()),
172+
)
173+
return nil
174+
}
175+
}
176+
// Check if the cursor is outside the type, if so ignore.
177+
if !def.Type().IsZero() {
178+
typeSpan := def.Type().Span()
179+
within := reportSpanToProtocolRange(typeSpan)
180+
if positionInRange(position, within) > 0 {
181+
file.lsp.logger.DebugContext(
182+
ctx,
183+
"completion: ignoring definition passed type",
184+
slog.String("def_kind", def.Classify().String()),
185+
)
186+
return nil
187+
}
188+
}
189+
190+
switch def.Classify() {
191+
case ast.DefKindMessage:
192+
return nil
193+
case ast.DefKindService:
194+
return nil
195+
case ast.DefKindEnum:
196+
return nil
197+
default:
198+
// If this is an invalid definition at the top level, return top-level keywords.
199+
if len(declPath) == 1 {
200+
file.lsp.logger.DebugContext(ctx, "completion: unknown definition at top level, returning top-level keywords")
201+
return slices.Collect(topLevelCompletionItems())
202+
}
203+
file.lsp.logger.DebugContext(ctx, "completion: unknown definition type (not at top level)")
204+
return nil
205+
}
206+
}
207+
208+
// completionItemsForImport returns completion items for import declarations.
209+
//
210+
// Suggest all importable files.
211+
func completionItemsForImport(ctx context.Context, file *file) []protocol.CompletionItem {
212+
file.lsp.logger.DebugContext(ctx, "completion: import declaration", slog.Int("importable_count", len(file.importToFile)))
213+
214+
items := make([]protocol.CompletionItem, 0, len(file.importToFile))
215+
for importPath := range file.importToFile {
216+
items = append(items, protocol.CompletionItem{
217+
Label: " \"" + importPath + "\";",
218+
Kind: protocol.CompletionItemKindFile,
219+
})
220+
}
221+
slices.SortFunc(items, func(a, b protocol.CompletionItem) int {
222+
return strings.Compare(strings.ToLower(a.Label), strings.ToLower(b.Label))
223+
})
224+
return items
225+
}
226+
227+
// topLevelCompletionItems returns completion items for top-level proto keywords.
228+
func topLevelCompletionItems() iter.Seq[protocol.CompletionItem] {
229+
return func(yield func(protocol.CompletionItem) bool) {
230+
_ = yield(keywordToCompletionItem(keyword.Syntax)) &&
231+
yield(keywordToCompletionItem(keyword.Edition)) &&
232+
yield(keywordToCompletionItem(keyword.Import)) &&
233+
yield(keywordToCompletionItem(keyword.Package)) &&
234+
yield(keywordToCompletionItem(keyword.Message)) &&
235+
yield(keywordToCompletionItem(keyword.Service)) &&
236+
yield(keywordToCompletionItem(keyword.Option)) &&
237+
yield(keywordToCompletionItem(keyword.Enum)) &&
238+
yield(keywordToCompletionItem(keyword.Extend))
239+
}
240+
}
241+
242+
// keywordToCompletionItem converts a keyword to a completion item.
243+
func keywordToCompletionItem(kw keyword.Keyword) protocol.CompletionItem {
244+
return protocol.CompletionItem{
245+
Label: kw.String(),
246+
Kind: protocol.CompletionItemKindKeyword,
247+
}
248+
}
249+
250+
// getDeclForPosition finds the path of AST declarations from parent to smallest that contains the given protocol position.
251+
// Returns a slice where [0] is the top-level declaration and [len-1] is the smallest/innermost declaration.
252+
// Returns nil if no declaration contains the position.
253+
func getDeclForPosition(body ast.DeclBody, position protocol.Position) []ast.DeclAny {
254+
return getDeclForPositionHelper(body, position, nil)
255+
}
256+
257+
// getDeclForPositionHelper is the recursive helper for getDeclForPosition.
258+
func getDeclForPositionHelper(body ast.DeclBody, position protocol.Position, path []ast.DeclAny) []ast.DeclAny {
259+
smallestSize := -1
260+
var bestPath []ast.DeclAny
261+
262+
for decl := range seq.Values(body.Decls()) {
263+
if decl.IsZero() {
264+
continue
265+
}
266+
span := decl.Span()
267+
if span.IsZero() {
268+
continue
269+
}
270+
271+
// Check if the position is within this declaration's span.
272+
within := reportSpanToProtocolRange(span)
273+
if positionInRange(position, within) == 0 {
274+
// Build the new path including this declaration.
275+
newPath := append(append([]ast.DeclAny(nil), path...), decl)
276+
size := span.End - span.Start
277+
if smallestSize == -1 || size < smallestSize {
278+
bestPath = newPath
279+
smallestSize = size
280+
}
281+
282+
// If this is a definition with a body, search recursively.
283+
if decl.Kind() == ast.DeclKindDef && !decl.AsDef().Body().IsZero() {
284+
childPath := getDeclForPositionHelper(decl.AsDef().Body(), position, newPath)
285+
if len(childPath) > 0 {
286+
childSize := childPath[len(childPath)-1].Span().End - childPath[len(childPath)-1].Span().Start
287+
if childSize < smallestSize {
288+
bestPath = childPath
289+
smallestSize = childSize
290+
}
291+
}
292+
}
293+
}
294+
}
295+
return bestPath
296+
}
297+
298+
// resolveCompletionItem resolves additional details for a completion item.
299+
//
300+
// This function is called by the CompletionResolve handler in server.go.
301+
func resolveCompletionItem(
302+
ctx context.Context,
303+
item *protocol.CompletionItem,
304+
) (*protocol.CompletionItem, error) {
305+
// TODO: Implement completion resolution logic.
306+
// For now, just return the item unchanged.
307+
return item, nil
308+
}

private/buf/buflsp/server.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ func (s *server) Initialize(
123123
IncludeText: false,
124124
},
125125
},
126+
CompletionProvider: &protocol.CompletionOptions{
127+
ResolveProvider: true,
128+
TriggerCharacters: []string{" ", ".", "("},
129+
},
126130
DefinitionProvider: &protocol.DefinitionOptions{
127131
WorkDoneProgressOptions: protocol.WorkDoneProgressOptions{WorkDoneProgress: true},
128132
},
@@ -418,6 +422,27 @@ func (s *server) References(
418422
return symbol.References(), nil
419423
}
420424

425+
// Completion is the entry point for code completion.
426+
func (s *server) Completion(
427+
ctx context.Context,
428+
params *protocol.CompletionParams,
429+
) (*protocol.CompletionList, error) {
430+
file := s.fileManager.Get(params.TextDocument.URI)
431+
if file == nil {
432+
return nil, nil
433+
}
434+
items := getCompletionItems(ctx, file, params.Position)
435+
return &protocol.CompletionList{Items: items}, nil
436+
}
437+
438+
// CompletionResolve is the entry point for resolving additional details for a completion item.
439+
func (s *server) CompletionResolve(
440+
ctx context.Context,
441+
params *protocol.CompletionItem,
442+
) (*protocol.CompletionItem, error) {
443+
return resolveCompletionItem(ctx, params)
444+
}
445+
421446
// SemanticTokensFull is called to render semantic token information on the client.
422447
func (s *server) SemanticTokensFull(
423448
ctx context.Context,

private/buf/buflsp/symbol.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ func commentToMarkdown(comment string) string {
375375
return strings.TrimSuffix(strings.TrimPrefix(comment, "/*"), "*/")
376376
}
377377

378-
// comparePositions compares two ranges for lexicographic ordering.
378+
// comparePositions compares two positions for lexicographic ordering.
379379
func comparePositions(a, b protocol.Position) int {
380380
diff := int(a.Line) - int(b.Line)
381381
if diff == 0 {
@@ -384,6 +384,17 @@ func comparePositions(a, b protocol.Position) int {
384384
return diff
385385
}
386386

387+
// positionInRange returns 0 if a position is within the range, else returns -1 before or 1 after.
388+
func positionInRange(position protocol.Position, within protocol.Range) int {
389+
if comparePositions(position, within.Start) < 0 {
390+
return -1
391+
}
392+
if comparePositions(position, within.End) > 0 {
393+
return 1
394+
}
395+
return 0
396+
}
397+
387398
func reportSpanToProtocolRange(span report.Span) protocol.Range {
388399
startLocation := span.File.Location(span.Start, positionalEncoding)
389400
endLocation := span.File.Location(span.End, positionalEncoding)

0 commit comments

Comments
 (0)