Skip to content

Commit e672ab5

Browse files
Add edition 2024 support to LSP
This adds edition 2024 support to the LSP, notably in completion, semantic tokens, and hover. Intended to be a fast-follow to #4407.
1 parent d5155ca commit e672ab5

11 files changed

Lines changed: 403 additions & 13 deletions

File tree

private/buf/buflsp/builtin.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,15 @@ var builtinDocs = map[string][]string{
109109
"map": {
110110
"A set of distinct keys, each of which is associated with a value.",
111111
},
112+
113+
"export": {
114+
"Marks a definition as exported, making it visible outside the current file.",
115+
"",
116+
"Available in edition 2024 and later.",
117+
},
118+
"local": {
119+
"Marks a definition as local, restricting its visibility to the current file.",
120+
"",
121+
"Available in edition 2024 and later.",
122+
},
112123
}

private/buf/buflsp/completion.go

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA
282282
hasStart := false // Start is a newline or open parenthesis for the start of a definition
283283
hasTypeModifier := false
284284
hasDeclaration := false
285+
hasVisibilityModifier := false
285286
typeSpan := extractAroundOffset(file, offset,
286287
func(tok token.Token) bool {
287288
if isTokenTypeDelimiter(tok) {
@@ -296,6 +297,8 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA
296297
hasDeclaration = hasDeclaration || isDeclaration
297298
_, isFieldModifier := typeModifierSet[tok.Keyword()]
298299
hasTypeModifier = hasTypeModifier || isFieldModifier
300+
_, isVisibilityMod := visibilityModifierSet[tok.Keyword()]
301+
hasVisibilityModifier = hasVisibilityModifier || isVisibilityMod
299302
}
300303
}
301304
if isTokenSpace(tok) {
@@ -340,6 +343,7 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA
340343
slog.Bool("has_start", hasStart),
341344
slog.Bool("has_field_modifier", hasTypeModifier),
342345
slog.Bool("has_declaration", hasDeclaration),
346+
slog.Bool("has_visibility_modifier", hasVisibilityModifier),
343347
slog.Bool("inside_map_type", insideMapType),
344348
slog.Bool("is_map_key_position", isMapKeyPosition),
345349
)
@@ -400,13 +404,29 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA
400404
return completionItemsForOptions(ctx, file, parentDef, def, offset)
401405
}
402406

403-
// If at the top level, and on the first item, return top level keywords.
407+
// If at the top level, return top level keywords.
404408
if parentDef.IsZero() {
405-
showKeywords := beforeCount == 0
406-
if showKeywords {
409+
editions := isEditions(file)
410+
switch {
411+
case beforeCount == 0:
412+
// At the start of a definition: show all top-level keywords, plus
413+
// visibility modifiers in edition 2024+ files.
407414
file.lsp.logger.DebugContext(ctx, "completion: definition returning top-level keywords")
415+
kws := topLevelKeywords()
416+
if editions {
417+
kws = joinSequences(kws, visibilityModifierKeywords())
418+
}
419+
return slices.Collect(keywordToCompletionItem(
420+
kws,
421+
protocol.CompletionItemKindKeyword,
422+
tokenSpan,
423+
offset,
424+
))
425+
case editions && beforeCount == 1 && hasVisibilityModifier:
426+
// After export/local, only type declaration keywords are valid.
427+
file.lsp.logger.DebugContext(ctx, "completion: definition returning top-level type declaration keywords after visibility modifier")
408428
return slices.Collect(keywordToCompletionItem(
409-
topLevelKeywords(),
429+
topLevelTypeDeclarationKeywords(),
410430
protocol.CompletionItemKindKeyword,
411431
tokenSpan,
412432
offset,
@@ -422,13 +442,13 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA
422442
// - Show keywords for the first values (but not when inside map key position)
423443
// - Show types if no type declaration and at first, or second position with field modifier.
424444
// - Always show types if cursor is inside map<...> angle brackets
445+
editions := isEditions(file)
425446
showKeywords := beforeCount == 0 && !(insideMapType && isMapKeyPosition)
426447
showTypes := insideMapType || (!hasDeclaration && (beforeCount == 0 || (hasTypeModifier && beforeCount == 1)))
427448
if showKeywords {
428-
isProto2 := isProto2(file)
429449
iters = append(iters,
430450
keywordToCompletionItem(
431-
messageLevelKeywords(isProto2),
451+
messageLevelKeywords(isProto2(file)),
432452
protocol.CompletionItemKindKeyword,
433453
tokenSpan,
434454
offset,
@@ -440,6 +460,22 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA
440460
offset,
441461
),
442462
)
463+
if editions {
464+
iters = append(iters, keywordToCompletionItem(
465+
visibilityModifierKeywords(),
466+
protocol.CompletionItemKindKeyword,
467+
tokenSpan,
468+
offset,
469+
))
470+
}
471+
} else if editions && beforeCount == 1 && hasVisibilityModifier {
472+
// After export/local inside a message, only nested type declarations are valid.
473+
iters = append(iters, keywordToCompletionItem(
474+
messageLevelTypeDeclarationKeywords(),
475+
protocol.CompletionItemKindKeyword,
476+
tokenSpan,
477+
offset,
478+
))
443479
}
444480
if showTypes {
445481
// When inside map angle brackets, use only the prefix of the tokenSpan for filtering
@@ -818,6 +854,15 @@ var typeModifierSet = func() map[keyword.Keyword]struct{} {
818854
return m
819855
}()
820856

857+
// visibilityModifierSet is the set of edition 2024+ visibility modifier keywords.
858+
var visibilityModifierSet = func() map[keyword.Keyword]struct{} {
859+
m := make(map[keyword.Keyword]struct{})
860+
for kw := range visibilityModifierKeywords() {
861+
m[kw] = struct{}{}
862+
}
863+
return m
864+
}()
865+
821866
// topLevelKeywords returns keywords for the top-level.
822867
func topLevelKeywords() iter.Seq[keyword.Keyword] {
823868
return func(yield func(keyword.Keyword) bool) {
@@ -833,6 +878,33 @@ func topLevelKeywords() iter.Seq[keyword.Keyword] {
833878
}
834879
}
835880

881+
// topLevelTypeDeclarationKeywords returns the type declaration keywords that can
882+
// follow a visibility modifier (export/local) at the top level in edition 2024+.
883+
func topLevelTypeDeclarationKeywords() iter.Seq[keyword.Keyword] {
884+
return func(yield func(keyword.Keyword) bool) {
885+
_ = yield(keyword.Message) &&
886+
yield(keyword.Enum) &&
887+
yield(keyword.Service)
888+
}
889+
}
890+
891+
// messageLevelTypeDeclarationKeywords returns the type declaration keywords that can
892+
// follow a visibility modifier (export/local) inside a message in edition 2024+.
893+
func messageLevelTypeDeclarationKeywords() iter.Seq[keyword.Keyword] {
894+
return func(yield func(keyword.Keyword) bool) {
895+
_ = yield(keyword.Message) &&
896+
yield(keyword.Enum)
897+
}
898+
}
899+
900+
// visibilityModifierKeywords returns the visibility modifier keywords for edition 2024+.
901+
func visibilityModifierKeywords() iter.Seq[keyword.Keyword] {
902+
return func(yield func(keyword.Keyword) bool) {
903+
_ = yield(keyword.Export) &&
904+
yield(keyword.Local)
905+
}
906+
}
907+
836908
// messageLevelKeywords returns keywords for messages.
837909
func messageLevelKeywords(isProto2 bool) iter.Seq[keyword.Keyword] {
838910
return func(yield func(keyword.Keyword) bool) {
@@ -1782,6 +1854,11 @@ func isProto2(file *file) bool {
17821854
return file.ir.Syntax() == syntax.Proto2
17831855
}
17841856

1857+
// isEditions returns true if the file uses editions syntax.
1858+
func isEditions(file *file) bool {
1859+
return file.ir.Syntax().IsEdition()
1860+
}
1861+
17851862
// findTypeBySpan returns the IR Type that corresponds to the given AST span.
17861863
// Returns a zero Type if no matching type is found.
17871864
func findTypeBySpan(file *file, span source.Span) ir.Type {

private/buf/buflsp/completion_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,3 +514,94 @@ func TestCompletionOptions(t *testing.T) {
514514
})
515515
}
516516
}
517+
518+
func TestCompletionEdition2024(t *testing.T) {
519+
t.Parallel()
520+
521+
ctx := t.Context()
522+
523+
testProtoPath, err := filepath.Abs("testdata/completion/edition2024_test.proto")
524+
require.NoError(t, err)
525+
526+
clientJSONConn, testURI := setupLSPServer(t, testProtoPath)
527+
528+
tests := []struct {
529+
name string
530+
line uint32
531+
character uint32
532+
expectedContains []string
533+
expectedNotContains []string
534+
}{
535+
{
536+
// At top level, "ex" prefix matches "export": visibility modifier is offered.
537+
name: "toplevel_export_keyword",
538+
line: 5, // "ex"
539+
character: 2, // After the "ex"
540+
expectedContains: []string{"export"},
541+
},
542+
{
543+
// At top level, "lo" prefix matches "local": visibility modifier is offered.
544+
name: "toplevel_local_keyword",
545+
line: 8, // "lo"
546+
character: 2, // After the "lo"
547+
expectedContains: []string{"local"},
548+
},
549+
{
550+
// After "export " at top level, "mess" prefix matches "message".
551+
// Only type declaration keywords (message, enum, service) should be offered.
552+
name: "after_export_toplevel",
553+
line: 11, // "export mess"
554+
character: 11, // After the "mess" in "export mess"
555+
expectedContains: []string{"message"},
556+
// Non-type-declaration top-level keywords should not be offered.
557+
expectedNotContains: []string{"edition", "import", "package", "option", "syntax"},
558+
},
559+
{
560+
// Inside a message, "lo" prefix matches "local": visibility modifier is offered.
561+
name: "message_local_keyword",
562+
line: 15, // " lo" (inside ExportedMessage)
563+
character: 4, // After the "lo"
564+
expectedContains: []string{"local"},
565+
},
566+
{
567+
// After "local " inside a message, "mess" prefix matches "message".
568+
// Only nested type declaration keywords (message, enum) should be offered.
569+
name: "after_local_in_message",
570+
line: 18, // " local mess" (inside ExportedMessage)
571+
character: 12, // After the "mess" in " local mess"
572+
expectedContains: []string{"message"},
573+
// Services cannot be nested; other keywords should not appear.
574+
expectedNotContains: []string{"service", "option", "oneof", "repeated", "optional"},
575+
},
576+
}
577+
578+
for _, tt := range tests {
579+
t.Run(tt.name, func(t *testing.T) {
580+
t.Parallel()
581+
var completionList *protocol.CompletionList
582+
_, completionErr := clientJSONConn.Call(ctx, protocol.MethodTextDocumentCompletion, protocol.CompletionParams{
583+
TextDocumentPositionParams: protocol.TextDocumentPositionParams{
584+
TextDocument: protocol.TextDocumentIdentifier{
585+
URI: testURI,
586+
},
587+
Position: protocol.Position{
588+
Line: tt.line,
589+
Character: tt.character,
590+
},
591+
},
592+
}, &completionList)
593+
require.NoError(t, completionErr)
594+
require.NotNil(t, completionList, "expected completion list to be non-nil")
595+
labels := make([]string, 0, len(completionList.Items))
596+
for _, item := range completionList.Items {
597+
labels = append(labels, item.Label)
598+
}
599+
for _, expected := range tt.expectedContains {
600+
assert.Contains(t, labels, expected, "expected completion list to contain %q", expected)
601+
}
602+
for _, notExpected := range tt.expectedNotContains {
603+
assert.NotContains(t, labels, notExpected, "expected completion list to not contain %q", notExpected)
604+
}
605+
})
606+
}
607+
}

private/buf/buflsp/file.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import (
4141
"github.com/bufbuild/protocompile/experimental/report"
4242
"github.com/bufbuild/protocompile/experimental/seq"
4343
"github.com/bufbuild/protocompile/experimental/source"
44+
"github.com/bufbuild/protocompile/experimental/token/keyword"
4445
"go.lsp.dev/protocol"
4546
)
4647

@@ -537,6 +538,7 @@ func (f *file) irToSymbols(irSymbol ir.Symbol) ([]*symbol, []*symbol) {
537538
}
538539
msg.def = msg
539540
resolved = append(resolved, msg)
541+
resolved = append(resolved, f.visibilityPrefixSymbols(irSymbol, irSymbol.AsType().AST())...)
540542
unresolved = append(unresolved, f.messageToSymbols(irSymbol.AsType().Options())...)
541543
case ir.SymbolKindEnum:
542544
enum := &symbol{
@@ -549,6 +551,7 @@ func (f *file) irToSymbols(irSymbol ir.Symbol) ([]*symbol, []*symbol) {
549551
}
550552
enum.def = enum
551553
resolved = append(resolved, enum)
554+
resolved = append(resolved, f.visibilityPrefixSymbols(irSymbol, irSymbol.AsType().AST())...)
552555
unresolved = append(unresolved, f.messageToSymbols(irSymbol.AsType().Options())...)
553556
case ir.SymbolKindEnumValue:
554557
name := &symbol{
@@ -723,6 +726,7 @@ func (f *file) irToSymbols(irSymbol ir.Symbol) ([]*symbol, []*symbol) {
723726
}
724727
service.def = service
725728
resolved = append(resolved, service)
729+
resolved = append(resolved, f.visibilityPrefixSymbols(irSymbol, irSymbol.AsService().AST())...)
726730
unresolved = append(unresolved, f.messageToSymbols(irSymbol.AsService().Options())...)
727731
case ir.SymbolKindMethod:
728732
method := &symbol{
@@ -837,6 +841,30 @@ func getKindForMapType(typeAST ast.TypeAny, mapField ir.Member, isKey bool) (kin
837841
return &static{ast: mapField.AST()}, false
838842
}
839843

844+
// visibilityPrefixSymbols returns keywordBuiltin symbols for any export/local visibility
845+
// prefix tokens on the given decl, to support hover documentation on those keywords.
846+
func (f *file) visibilityPrefixSymbols(irSymbol ir.Symbol, decl ast.DeclDef) []*symbol {
847+
var syms []*symbol
848+
for prefix := range decl.Prefixes() {
849+
kw := prefix.Prefix()
850+
if kw != keyword.Export && kw != keyword.Local {
851+
continue
852+
}
853+
prefixTok := prefix.PrefixToken()
854+
if prefixTok.IsZero() {
855+
continue
856+
}
857+
kwSym := &symbol{
858+
ir: irSymbol,
859+
file: f,
860+
span: prefixTok.Span(),
861+
kind: &keywordBuiltin{name: kw.String(), anchor: "symbol-visibility"},
862+
}
863+
syms = append(syms, kwSym)
864+
}
865+
return syms
866+
}
867+
840868
// importToSymbol takes an [ir.Import] and returns a symbol for it.
841869
func (f *file) importToSymbol(imp ir.Import) *symbol {
842870
return &symbol{

private/buf/buflsp/hover_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,27 @@ func TestHover(t *testing.T) {
181181
character: 8, // On "TestTopLevel"
182182
expectNoHover: true,
183183
},
184+
{
185+
name: "hover_on_export_keyword",
186+
protoFile: "testdata/hover/edition2024.proto",
187+
line: 5, // Line with "export message ExportedMessage {"
188+
character: 1, // On "export"
189+
expectedContains: "symbol-visibility",
190+
},
191+
{
192+
name: "hover_on_local_keyword",
193+
protoFile: "testdata/hover/edition2024.proto",
194+
line: 10, // Line with "local message LocalMessage {"
195+
character: 1, // On "local"
196+
expectedContains: "symbol-visibility",
197+
},
198+
{
199+
name: "hover_on_exported_message_name",
200+
protoFile: "testdata/hover/edition2024.proto",
201+
line: 5, // Line with "export message ExportedMessage {"
202+
character: 16, // On "ExportedMessage"
203+
expectedContains: "ExportedMessage is visible outside this file",
204+
},
184205
}
185206

186207
for _, tt := range tests {

0 commit comments

Comments
 (0)