diff --git a/private/buf/buflsp/builtin.go b/private/buf/buflsp/builtin.go index 32eecc62f5..9b395e9281 100644 --- a/private/buf/buflsp/builtin.go +++ b/private/buf/buflsp/builtin.go @@ -109,4 +109,15 @@ var builtinDocs = map[string][]string{ "map": { "A set of distinct keys, each of which is associated with a value.", }, + + "export": { + "Marks a definition as exported, making it visible outside the current file.", + "", + "Available in edition 2024 and later.", + }, + "local": { + "Marks a definition as local, restricting its visibility to the current file.", + "", + "Available in edition 2024 and later.", + }, } diff --git a/private/buf/buflsp/completion.go b/private/buf/buflsp/completion.go index 483f31a52d..34d29c033c 100644 --- a/private/buf/buflsp/completion.go +++ b/private/buf/buflsp/completion.go @@ -282,6 +282,7 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA hasStart := false // Start is a newline or open parenthesis for the start of a definition hasTypeModifier := false hasDeclaration := false + hasVisibilityModifier := false typeSpan := extractAroundOffset(file, offset, func(tok token.Token) bool { if isTokenTypeDelimiter(tok) { @@ -296,6 +297,8 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA hasDeclaration = hasDeclaration || isDeclaration _, isFieldModifier := typeModifierSet[tok.Keyword()] hasTypeModifier = hasTypeModifier || isFieldModifier + _, isVisibilityMod := visibilityModifierSet[tok.Keyword()] + hasVisibilityModifier = hasVisibilityModifier || isVisibilityMod } } if isTokenSpace(tok) { @@ -340,6 +343,7 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA slog.Bool("has_start", hasStart), slog.Bool("has_field_modifier", hasTypeModifier), slog.Bool("has_declaration", hasDeclaration), + slog.Bool("has_visibility_modifier", hasVisibilityModifier), slog.Bool("inside_map_type", insideMapType), slog.Bool("is_map_key_position", isMapKeyPosition), ) @@ -400,13 +404,29 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA return completionItemsForOptions(ctx, file, parentDef, def, offset) } - // If at the top level, and on the first item, return top level keywords. + // If at the top level, return top level keywords. if parentDef.IsZero() { - showKeywords := beforeCount == 0 - if showKeywords { + editions := isEditions(file) + switch { + case beforeCount == 0: + // At the start of a definition: show all top-level keywords, plus + // visibility modifiers in edition 2024+ files. file.lsp.logger.DebugContext(ctx, "completion: definition returning top-level keywords") + kws := topLevelKeywords() + if editions { + kws = joinSequences(kws, visibilityModifierKeywords()) + } + return slices.Collect(keywordToCompletionItem( + kws, + protocol.CompletionItemKindKeyword, + tokenSpan, + offset, + )) + case editions && beforeCount == 1 && hasVisibilityModifier: + // After export/local, only type declaration keywords are valid. + file.lsp.logger.DebugContext(ctx, "completion: definition returning top-level type declaration keywords after visibility modifier") return slices.Collect(keywordToCompletionItem( - topLevelKeywords(), + topLevelTypeDeclarationKeywords(), protocol.CompletionItemKindKeyword, tokenSpan, offset, @@ -422,13 +442,13 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA // - Show keywords for the first values (but not when inside map key position) // - Show types if no type declaration and at first, or second position with field modifier. // - Always show types if cursor is inside map<...> angle brackets + editions := isEditions(file) showKeywords := beforeCount == 0 && !(insideMapType && isMapKeyPosition) showTypes := insideMapType || (!hasDeclaration && (beforeCount == 0 || (hasTypeModifier && beforeCount == 1))) if showKeywords { - isProto2 := isProto2(file) iters = append(iters, keywordToCompletionItem( - messageLevelKeywords(isProto2), + messageLevelKeywords(isProto2(file)), protocol.CompletionItemKindKeyword, tokenSpan, offset, @@ -440,6 +460,22 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA offset, ), ) + if editions { + iters = append(iters, keywordToCompletionItem( + visibilityModifierKeywords(), + protocol.CompletionItemKindKeyword, + tokenSpan, + offset, + )) + } + } else if editions && beforeCount == 1 && hasVisibilityModifier { + // After export/local inside a message, only nested type declarations are valid. + iters = append(iters, keywordToCompletionItem( + messageLevelTypeDeclarationKeywords(), + protocol.CompletionItemKindKeyword, + tokenSpan, + offset, + )) } if showTypes { // 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{} { return m }() +// visibilityModifierSet is the set of edition 2024+ visibility modifier keywords. +var visibilityModifierSet = func() map[keyword.Keyword]struct{} { + m := make(map[keyword.Keyword]struct{}) + for kw := range visibilityModifierKeywords() { + m[kw] = struct{}{} + } + return m +}() + // topLevelKeywords returns keywords for the top-level. func topLevelKeywords() iter.Seq[keyword.Keyword] { return func(yield func(keyword.Keyword) bool) { @@ -833,6 +878,33 @@ func topLevelKeywords() iter.Seq[keyword.Keyword] { } } +// topLevelTypeDeclarationKeywords returns the type declaration keywords that can +// follow a visibility modifier (export/local) at the top level in edition 2024+. +func topLevelTypeDeclarationKeywords() iter.Seq[keyword.Keyword] { + return func(yield func(keyword.Keyword) bool) { + _ = yield(keyword.Message) && + yield(keyword.Enum) && + yield(keyword.Service) + } +} + +// messageLevelTypeDeclarationKeywords returns the type declaration keywords that can +// follow a visibility modifier (export/local) inside a message in edition 2024+. +func messageLevelTypeDeclarationKeywords() iter.Seq[keyword.Keyword] { + return func(yield func(keyword.Keyword) bool) { + _ = yield(keyword.Message) && + yield(keyword.Enum) + } +} + +// visibilityModifierKeywords returns the visibility modifier keywords for edition 2024+. +func visibilityModifierKeywords() iter.Seq[keyword.Keyword] { + return func(yield func(keyword.Keyword) bool) { + _ = yield(keyword.Export) && + yield(keyword.Local) + } +} + // messageLevelKeywords returns keywords for messages. func messageLevelKeywords(isProto2 bool) iter.Seq[keyword.Keyword] { return func(yield func(keyword.Keyword) bool) { @@ -1782,6 +1854,11 @@ func isProto2(file *file) bool { return file.ir.Syntax() == syntax.Proto2 } +// isEditions returns true if the file uses editions syntax. +func isEditions(file *file) bool { + return file.ir.Syntax().IsEdition() +} + // findTypeBySpan returns the IR Type that corresponds to the given AST span. // Returns a zero Type if no matching type is found. func findTypeBySpan(file *file, span source.Span) ir.Type { diff --git a/private/buf/buflsp/completion_test.go b/private/buf/buflsp/completion_test.go index 845aa0691a..21aed80382 100644 --- a/private/buf/buflsp/completion_test.go +++ b/private/buf/buflsp/completion_test.go @@ -514,3 +514,94 @@ func TestCompletionOptions(t *testing.T) { }) } } + +func TestCompletionEdition2024(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + testProtoPath, err := filepath.Abs("testdata/completion/edition2024_test.proto") + require.NoError(t, err) + + clientJSONConn, testURI := setupLSPServer(t, testProtoPath) + + tests := []struct { + name string + line uint32 + character uint32 + expectedContains []string + expectedNotContains []string + }{ + { + // At top level, "ex" prefix matches "export": visibility modifier is offered. + name: "toplevel_export_keyword", + line: 5, // "ex" + character: 2, // After the "ex" + expectedContains: []string{"export"}, + }, + { + // At top level, "lo" prefix matches "local": visibility modifier is offered. + name: "toplevel_local_keyword", + line: 8, // "lo" + character: 2, // After the "lo" + expectedContains: []string{"local"}, + }, + { + // After "export " at top level, "mess" prefix matches "message". + // Only type declaration keywords (message, enum, service) should be offered. + name: "after_export_toplevel", + line: 11, // "export mess" + character: 11, // After the "mess" in "export mess" + expectedContains: []string{"message"}, + // Non-type-declaration top-level keywords should not be offered. + expectedNotContains: []string{"edition", "import", "package", "option", "syntax"}, + }, + { + // Inside a message, "lo" prefix matches "local": visibility modifier is offered. + name: "message_local_keyword", + line: 15, // " lo" (inside ExportedMessage) + character: 4, // After the "lo" + expectedContains: []string{"local"}, + }, + { + // After "local " inside a message, "mess" prefix matches "message". + // Only nested type declaration keywords (message, enum) should be offered. + name: "after_local_in_message", + line: 18, // " local mess" (inside ExportedMessage) + character: 12, // After the "mess" in " local mess" + expectedContains: []string{"message"}, + // Services cannot be nested; other keywords should not appear. + expectedNotContains: []string{"service", "option", "oneof", "repeated", "optional"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var completionList *protocol.CompletionList + _, completionErr := clientJSONConn.Call(ctx, protocol.MethodTextDocumentCompletion, protocol.CompletionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{ + URI: testURI, + }, + Position: protocol.Position{ + Line: tt.line, + Character: tt.character, + }, + }, + }, &completionList) + require.NoError(t, completionErr) + require.NotNil(t, completionList, "expected completion list to be non-nil") + labels := make([]string, 0, len(completionList.Items)) + for _, item := range completionList.Items { + labels = append(labels, item.Label) + } + for _, expected := range tt.expectedContains { + assert.Contains(t, labels, expected, "expected completion list to contain %q", expected) + } + for _, notExpected := range tt.expectedNotContains { + assert.NotContains(t, labels, notExpected, "expected completion list to not contain %q", notExpected) + } + }) + } +} diff --git a/private/buf/buflsp/file.go b/private/buf/buflsp/file.go index 8be8ff39ea..7aa7abb96c 100644 --- a/private/buf/buflsp/file.go +++ b/private/buf/buflsp/file.go @@ -41,6 +41,7 @@ import ( "github.com/bufbuild/protocompile/experimental/report" "github.com/bufbuild/protocompile/experimental/seq" "github.com/bufbuild/protocompile/experimental/source" + "github.com/bufbuild/protocompile/experimental/token/keyword" "go.lsp.dev/protocol" ) @@ -537,6 +538,7 @@ func (f *file) irToSymbols(irSymbol ir.Symbol) ([]*symbol, []*symbol) { } msg.def = msg resolved = append(resolved, msg) + resolved = append(resolved, f.visibilityPrefixSymbols(irSymbol, irSymbol.AsType().AST())...) unresolved = append(unresolved, f.messageToSymbols(irSymbol.AsType().Options())...) case ir.SymbolKindEnum: enum := &symbol{ @@ -549,6 +551,7 @@ func (f *file) irToSymbols(irSymbol ir.Symbol) ([]*symbol, []*symbol) { } enum.def = enum resolved = append(resolved, enum) + resolved = append(resolved, f.visibilityPrefixSymbols(irSymbol, irSymbol.AsType().AST())...) unresolved = append(unresolved, f.messageToSymbols(irSymbol.AsType().Options())...) case ir.SymbolKindEnumValue: name := &symbol{ @@ -723,6 +726,7 @@ func (f *file) irToSymbols(irSymbol ir.Symbol) ([]*symbol, []*symbol) { } service.def = service resolved = append(resolved, service) + resolved = append(resolved, f.visibilityPrefixSymbols(irSymbol, irSymbol.AsService().AST())...) unresolved = append(unresolved, f.messageToSymbols(irSymbol.AsService().Options())...) case ir.SymbolKindMethod: method := &symbol{ @@ -837,6 +841,30 @@ func getKindForMapType(typeAST ast.TypeAny, mapField ir.Member, isKey bool) (kin return &static{ast: mapField.AST()}, false } +// visibilityPrefixSymbols returns keywordBuiltin symbols for any export/local visibility +// prefix tokens on the given decl, to support hover documentation on those keywords. +func (f *file) visibilityPrefixSymbols(irSymbol ir.Symbol, decl ast.DeclDef) []*symbol { + var syms []*symbol + for prefix := range decl.Prefixes() { + kw := prefix.Prefix() + if kw != keyword.Export && kw != keyword.Local { + continue + } + prefixTok := prefix.PrefixToken() + if prefixTok.IsZero() { + continue + } + kwSym := &symbol{ + ir: irSymbol, + file: f, + span: prefixTok.Span(), + kind: &keywordBuiltin{name: kw.String(), anchor: "symbol-visibility"}, + } + syms = append(syms, kwSym) + } + return syms +} + // importToSymbol takes an [ir.Import] and returns a symbol for it. func (f *file) importToSymbol(imp ir.Import) *symbol { return &symbol{ diff --git a/private/buf/buflsp/hover_test.go b/private/buf/buflsp/hover_test.go index c9c0279c2e..4e98ccceae 100644 --- a/private/buf/buflsp/hover_test.go +++ b/private/buf/buflsp/hover_test.go @@ -181,6 +181,27 @@ func TestHover(t *testing.T) { character: 8, // On "TestTopLevel" expectNoHover: true, }, + { + name: "hover_on_export_keyword", + protoFile: "testdata/hover/edition2024.proto", + line: 5, // Line with "export message ExportedMessage {" + character: 1, // On "export" + expectedContains: "symbol-visibility", + }, + { + name: "hover_on_local_keyword", + protoFile: "testdata/hover/edition2024.proto", + line: 10, // Line with "local message LocalMessage {" + character: 1, // On "local" + expectedContains: "symbol-visibility", + }, + { + name: "hover_on_exported_message_name", + protoFile: "testdata/hover/edition2024.proto", + line: 5, // Line with "export message ExportedMessage {" + character: 16, // On "ExportedMessage" + expectedContains: "ExportedMessage is visible outside this file", + }, } for _, tt := range tests { diff --git a/private/buf/buflsp/semantic_tokens.go b/private/buf/buflsp/semantic_tokens.go index f039a2b329..d69addddc5 100644 --- a/private/buf/buflsp/semantic_tokens.go +++ b/private/buf/buflsp/semantic_tokens.go @@ -219,12 +219,28 @@ func semanticTokensFull(file *file, celEnv *cel.Env) (*protocol.SemanticTokens, case ir.SymbolKindPackage: semanticType = semanticTypeNamespace case ir.SymbolKindMessage: + // Collect export/local visibility prefix (edition 2024+) + for prefix := range symbol.ir.AsType().AST().Prefixes() { + if kw := prefix.Prefix(); kw == keyword.Export || kw == keyword.Local { + if prefixTok := prefix.PrefixToken(); !prefixTok.IsZero() { + collectToken(prefixTok.Span(), semanticTypeModifier, 0, kw) + } + } + } // Collect "message" keyword if kwTok := symbol.ir.AsType().AST().KeywordToken(); !kwTok.IsZero() { collectToken(kwTok.Span(), semanticTypeKeyword, 0, kwTok.Keyword()) } semanticType = semanticTypeStruct case ir.SymbolKindEnum: + // Collect export/local visibility prefix (edition 2024+) + for prefix := range symbol.ir.AsType().AST().Prefixes() { + if kw := prefix.Prefix(); kw == keyword.Export || kw == keyword.Local { + if prefixTok := prefix.PrefixToken(); !prefixTok.IsZero() { + collectToken(prefixTok.Span(), semanticTypeModifier, 0, kw) + } + } + } // Collect "enum" keyword if kwTok := symbol.ir.AsType().AST().KeywordToken(); !kwTok.IsZero() { collectToken(kwTok.Span(), semanticTypeKeyword, 0, kwTok.Keyword()) @@ -257,6 +273,14 @@ func semanticTokensFull(file *file, celEnv *cel.Env) (*protocol.SemanticTokens, semanticType = semanticTypeType semanticModifier += semanticModifierDefaultLibrary case ir.SymbolKindService: + // Collect export/local visibility prefix (edition 2024+) + for prefix := range symbol.ir.AsService().AST().Prefixes() { + if kw := prefix.Prefix(); kw == keyword.Export || kw == keyword.Local { + if prefixTok := prefix.PrefixToken(); !prefixTok.IsZero() { + collectToken(prefixTok.Span(), semanticTypeModifier, 0, kw) + } + } + } // Collect "service" keyword if kwTok := symbol.ir.AsService().AST().KeywordToken(); !kwTok.IsZero() { collectToken(kwTok.Span(), semanticTypeKeyword, 0, kwTok.Keyword()) diff --git a/private/buf/buflsp/semantic_tokens_test.go b/private/buf/buflsp/semantic_tokens_test.go index afb695c05e..406371cebe 100644 --- a/private/buf/buflsp/semantic_tokens_test.go +++ b/private/buf/buflsp/semantic_tokens_test.go @@ -116,6 +116,53 @@ func TestSemanticTokensKeywords(t *testing.T) { {6, 9, 4, semanticTypeProperty, "'name' as property"}, }, }, + { + name: "edition2024", + file: "testdata/semantic_tokens/edition2024.proto", + expectedTokens: []expectedToken{ + // edition declaration + {0, 0, 7, semanticTypeKeyword, "'edition' keyword"}, + {0, 10, 6, semanticTypeString, "'\"2024\"' string"}, + // package declaration + {2, 0, 7, semanticTypeKeyword, "'package' keyword"}, + {2, 8, 7, semanticTypeNamespace, "'test.v1' namespace"}, + // export message + {4, 0, 6, semanticTypeModifier, "'export' modifier"}, + {4, 7, 7, semanticTypeKeyword, "'message' keyword"}, + {4, 15, 7, semanticTypeStruct, "'Product' struct"}, + // string fields in Product + {5, 2, 6, semanticTypeType, "'string' type"}, + {5, 9, 2, semanticTypeProperty, "'id' property"}, + {5, 14, 1, semanticTypeNumber, "'1' field tag"}, + {6, 2, 6, semanticTypeType, "'string' type"}, + {6, 9, 4, semanticTypeProperty, "'name' property"}, + {6, 16, 1, semanticTypeNumber, "'2' field tag"}, + // local message + {9, 0, 5, semanticTypeModifier, "'local' modifier"}, + {9, 6, 7, semanticTypeKeyword, "'message' keyword"}, + {9, 14, 15, semanticTypeStruct, "'InternalProduct' struct"}, + // export enum + {13, 0, 6, semanticTypeModifier, "'export' modifier"}, + {13, 7, 4, semanticTypeKeyword, "'enum' keyword"}, + {13, 12, 6, semanticTypeEnum, "'Status' enum"}, + {14, 2, 18, semanticTypeEnumMember, "'STATUS_UNSPECIFIED' enum member"}, + {14, 23, 1, semanticTypeNumber, "'0' enum value"}, + // local enum + {17, 0, 5, semanticTypeModifier, "'local' modifier"}, + {17, 6, 4, semanticTypeKeyword, "'enum' keyword"}, + {17, 11, 14, semanticTypeEnum, "'InternalStatus' enum"}, + {18, 2, 27, semanticTypeEnumMember, "'INTERNAL_STATUS_UNSPECIFIED' enum member"}, + {18, 32, 1, semanticTypeNumber, "'0' enum value"}, + // export service + {21, 0, 6, semanticTypeModifier, "'export' modifier"}, + {21, 7, 7, semanticTypeKeyword, "'service' keyword"}, + {21, 15, 14, semanticTypeInterface, "'ProductService' interface"}, + // local service + {23, 0, 5, semanticTypeModifier, "'local' modifier"}, + {23, 6, 7, semanticTypeKeyword, "'service' keyword"}, + {23, 14, 15, semanticTypeInterface, "'InternalService' interface"}, + }, + }, { name: "comprehensive", file: "testdata/semantic_tokens/comprehensive.proto", diff --git a/private/buf/buflsp/symbol.go b/private/buf/buflsp/symbol.go index 4ee723c614..61c9ba744e 100644 --- a/private/buf/buflsp/symbol.go +++ b/private/buf/buflsp/symbol.go @@ -91,13 +91,19 @@ type builtin struct { type tag struct{} -func (*referenceable) isSymbolKind() {} -func (*reference) isSymbolKind() {} -func (*option) isSymbolKind() {} -func (*static) isSymbolKind() {} -func (*imported) isSymbolKind() {} -func (*builtin) isSymbolKind() {} -func (*tag) isSymbolKind() {} +type keywordBuiltin struct { + name string + anchor string +} + +func (*referenceable) isSymbolKind() {} +func (*reference) isSymbolKind() {} +func (*option) isSymbolKind() {} +func (*static) isSymbolKind() {} +func (*imported) isSymbolKind() {} +func (*builtin) isSymbolKind() {} +func (*tag) isSymbolKind() {} +func (*keywordBuiltin) isSymbolKind() {} // Range constructs an LSP protocol code range for this symbol. func (s *symbol) Range() protocol.Range { @@ -307,6 +313,22 @@ func (s *symbol) FormatDocs() string { return strings.Join(comments, "\n") } return "" + case *keywordBuiltin: + kwBuiltin, _ := s.kind.(*keywordBuiltin) + comments, ok := builtinDocs[kwBuiltin.name] + if ok { + comments = append( + comments, + "", + fmt.Sprintf( + "`%s` is a Protobuf keyword. [Learn more on protobuf.com.](https://protobuf.com/docs/language-spec#%s)", + kwBuiltin.name, + kwBuiltin.anchor, + ), + ) + return strings.Join(comments, "\n") + } + return "" case *referenceable, *static, *reference, *option: return s.getDocsFromComments() } diff --git a/private/buf/buflsp/testdata/completion/edition2024_test.proto b/private/buf/buflsp/testdata/completion/edition2024_test.proto new file mode 100644 index 0000000000..b1b8973a5c --- /dev/null +++ b/private/buf/buflsp/testdata/completion/edition2024_test.proto @@ -0,0 +1,20 @@ +edition = "2024"; + +package example.v1; + +// toplevel_export_keyword +ex + +// toplevel_local_keyword +lo + +// after_export_toplevel: cursor after "export mess" at col 11 +export mess + +export message ExportedMessage { + // message_local_keyword: cursor after " lo" at col 4 + lo + + // after_local_message: cursor after " local mess" at col 12 + local mess +} diff --git a/private/buf/buflsp/testdata/hover/edition2024.proto b/private/buf/buflsp/testdata/hover/edition2024.proto new file mode 100644 index 0000000000..876d16686d --- /dev/null +++ b/private/buf/buflsp/testdata/hover/edition2024.proto @@ -0,0 +1,25 @@ +edition = "2024"; + +package example.v1; + +// ExportedMessage is visible outside this file. +export message ExportedMessage { + string id = 1; +} + +// LocalMessage is only visible within this file. +local message LocalMessage { + string id = 1; +} + +export enum ExportedStatus { + EXPORTED_STATUS_UNSPECIFIED = 0; +} + +local enum LocalStatus { + LOCAL_STATUS_UNSPECIFIED = 0; +} + +export service ExportedService {} + +local service LocalService {} diff --git a/private/buf/buflsp/testdata/semantic_tokens/edition2024.proto b/private/buf/buflsp/testdata/semantic_tokens/edition2024.proto new file mode 100644 index 0000000000..c0f369a2d4 --- /dev/null +++ b/private/buf/buflsp/testdata/semantic_tokens/edition2024.proto @@ -0,0 +1,24 @@ +edition = "2024"; + +package test.v1; + +export message Product { + string id = 1; + string name = 2; +} + +local message InternalProduct { + string id = 1; +} + +export enum Status { + STATUS_UNSPECIFIED = 0; +} + +local enum InternalStatus { + INTERNAL_STATUS_UNSPECIFIED = 0; +} + +export service ProductService {} + +local service InternalService {}