Skip to content

Commit 5058bf9

Browse files
Add LSP completion for field numbers (#4260)
1 parent bb41ff4 commit 5058bf9

File tree

4 files changed

+165
-21
lines changed

4 files changed

+165
-21
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## [Unreleased]
44

55
- Update `PROTOVALIDATE` lint rule to support field mask rules.
6+
- Add LSP completion for field numbers.
67

78
## [v1.62.1] - 2025-12-29
89

private/buf/buflsp/completion.go

Lines changed: 122 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"log/slog"
2424
"math"
2525
"slices"
26+
"strconv"
2627
"strings"
2728

2829
"github.com/bufbuild/buf/private/pkg/normalpath"
@@ -35,6 +36,7 @@ import (
3536
"github.com/bufbuild/protocompile/experimental/token"
3637
"github.com/bufbuild/protocompile/experimental/token/keyword"
3738
"go.lsp.dev/protocol"
39+
"google.golang.org/protobuf/encoding/protowire"
3840
)
3941

4042
// getCompletionItems returns completion items for the given position in the file.
@@ -326,6 +328,22 @@ func completionItemsForDef(ctx context.Context, file *file, declPath []ast.DeclA
326328
slog.Bool("has_field_modifier", hasTypeModifier),
327329
slog.Bool("has_declaration", hasDeclaration),
328330
)
331+
// Special case: field number completion
332+
if !hasStart && parentDef.Classify() == ast.DefKindMessage {
333+
// Case 1: Valid field definition with type and name but no tag
334+
// e.g., `string name = |;\n`
335+
if def.Classify() == ast.DefKindField {
336+
field := def.AsField()
337+
if !field.Type.IsZero() && !field.Name.IsZero() && field.Tag.IsZero() {
338+
return completionItemsForFieldNumber(file, parentDef)
339+
}
340+
}
341+
// Case 2: Invalid definition (e.g., missing semicolon) but cursor is after equals sign
342+
// e.g., `string name = |`
343+
if def.Classify() == ast.DefKindInvalid && isAfterEqualsSign(file, offset) {
344+
return completionItemsForFieldNumber(file, parentDef)
345+
}
346+
}
329347
if !hasStart {
330348
file.lsp.logger.DebugContext(
331349
ctx,
@@ -631,13 +649,7 @@ func completionItemsForOptions(
631649
}
632650
// Complete options within the value or the path.
633651
if offsetInSpan(offset, def.Value().Span()) == 0 {
634-
var parentType ir.Type
635-
for irType := range seq.Values(file.ir.AllTypes()) {
636-
if irType.AST().Span() == parentDef.Span() {
637-
parentType = irType
638-
break
639-
}
640-
}
652+
parentType := findTypeBySpan(file, parentDef.Span())
641653
optionType, isOptionType := getOptionValueType(file, ctx, parentType.Options(), offset)
642654
if !isOptionType {
643655
file.lsp.logger.DebugContext(
@@ -1439,16 +1451,14 @@ func defToOptionMessage(file *file, def ast.DeclDef) ir.MessageValue {
14391451
defSpan := def.Span()
14401452
switch kind := def.Classify(); kind {
14411453
case ast.DefKindMessage:
1442-
for irType := range seq.Values(file.ir.AllTypes()) {
1443-
if irType.AST().Span() == defSpan {
1444-
return irType.Options()
1445-
}
1454+
irType := findTypeBySpan(file, defSpan)
1455+
if !irType.IsZero() {
1456+
return irType.Options()
14461457
}
14471458
case ast.DefKindEnum:
1448-
for irType := range seq.Values(file.ir.AllTypes()) {
1449-
if irType.AST().Span() == defSpan {
1450-
return irType.Options()
1451-
}
1459+
irType := findTypeBySpan(file, defSpan)
1460+
if !irType.IsZero() {
1461+
return irType.Options()
14521462
}
14531463
case ast.DefKindField:
14541464
for irType := range seq.Values(file.ir.AllTypes()) {
@@ -1497,6 +1507,22 @@ func defToOptionMessage(file *file, def ast.DeclDef) ir.MessageValue {
14971507
return ir.MessageValue{}
14981508
}
14991509

1510+
// isAfterEqualsSign checks if the cursor offset is positioned after an equals sign,
1511+
// which would indicate we're completing a field number.
1512+
func isAfterEqualsSign(file *file, offset int) bool {
1513+
if file.ir.AST() == nil || file.ir.AST().Stream() == nil {
1514+
return false
1515+
}
1516+
1517+
before, _ := file.ir.AST().Stream().Around(offset)
1518+
cursor := token.NewCursorAt(before)
1519+
1520+
if isTokenSpace(before) {
1521+
before = cursor.PrevSkippable()
1522+
}
1523+
return isTokenEqual(before)
1524+
}
1525+
15001526
// isTokenType returns true if the tokens are valid for a type declaration e.g "buf.registry.Type".
15011527
func isTokenType(tok token.Token) bool {
15021528
kind := tok.Kind()
@@ -1515,6 +1541,11 @@ func isTokenParen(tok token.Token) bool {
15151541
strings.HasSuffix(tok.Text(), ")"))
15161542
}
15171543

1544+
// isTokenEqual returns true for '=' tokens.
1545+
func isTokenEqual(tok token.Token) bool {
1546+
return tok.Kind() == token.Keyword && tok.Keyword() == keyword.Assign
1547+
}
1548+
15181549
// isTokenTypeDelimiter returns true if the token represents a delimiter for completion.
15191550
// A delimiter is a newline or start of stream. This handles invalid partial declarations.
15201551
func isTokenTypeDelimiter(tok token.Token) bool {
@@ -1592,18 +1623,30 @@ func isProto2(file *file) bool {
15921623
return file.ir.Syntax() == syntax.Proto2
15931624
}
15941625

1626+
// findTypeBySpan returns the IR Type that corresponds to the given AST span.
1627+
// Returns a zero Type if no matching type is found.
1628+
func findTypeBySpan(file *file, span source.Span) ir.Type {
1629+
if span.IsZero() {
1630+
return ir.Type{}
1631+
}
1632+
for t := range seq.Values(file.ir.AllTypes()) {
1633+
if t.AST().Span() == span {
1634+
return t
1635+
}
1636+
}
1637+
return ir.Type{}
1638+
}
1639+
15951640
// findTypeFullName simply loops through and finds the type definition name.
15961641
func findTypeFullName(file *file, declDef ast.DeclDef) ir.FullName {
15971642
declDefSpan := declDef.Span()
15981643
if declDefSpan.IsZero() {
15991644
return ""
16001645
}
1601-
for irType := range seq.Values(file.ir.AllTypes()) {
1602-
typeSpan := irType.AST().Span()
1603-
if typeSpan.Start == declDefSpan.Start && typeSpan.End == declDefSpan.End {
1604-
file.lsp.logger.Debug("completion: found parent type", slog.String("parent", string(irType.FullName())))
1605-
return irType.FullName()
1606-
}
1646+
irType := findTypeBySpan(file, declDefSpan)
1647+
if !irType.IsZero() {
1648+
file.lsp.logger.Debug("completion: found parent type", slog.String("parent", string(irType.FullName())))
1649+
return irType.FullName()
16071650
}
16081651
return ""
16091652
}
@@ -1634,6 +1677,64 @@ func getOptionValueType(file *file, ctx context.Context, optionValue ir.MessageV
16341677
return ir.Type{}, false
16351678
}
16361679

1680+
func completionItemsForFieldNumber(
1681+
file *file,
1682+
parentDef ast.DeclDef,
1683+
) []protocol.CompletionItem {
1684+
// Collect all used or reserved field numbers in the parent message
1685+
usedOrReservedFieldNumbers := make(map[uint64]bool)
1686+
1687+
// Find the IR Type corresponding to this AST definition
1688+
irType := findTypeBySpan(file, parentDef.Span())
1689+
1690+
if !irType.IsZero() {
1691+
// Collect field numbers from members
1692+
for member := range seq.Values(irType.Members()) {
1693+
if num := member.Number(); num > 0 {
1694+
usedOrReservedFieldNumbers[uint64(num)] = true
1695+
}
1696+
}
1697+
1698+
// Collect reserved ranges
1699+
for reservedRange := range seq.Values(irType.ReservedRanges()) {
1700+
start, end := reservedRange.Range()
1701+
for i := start; i <= end; i++ {
1702+
if i > 0 {
1703+
usedOrReservedFieldNumbers[uint64(i)] = true
1704+
}
1705+
}
1706+
}
1707+
}
1708+
1709+
// Find the next available field number, skipping:
1710+
// 1. Numbers already used or reserved in the message
1711+
// 2. The protobuf reserved range (19000-19999)
1712+
nextNumber := uint64(1)
1713+
for {
1714+
// Check if this number is available
1715+
if !usedOrReservedFieldNumbers[nextNumber] {
1716+
// Check if it's outside the protobuf reserved range
1717+
if nextNumber < uint64(protowire.FirstReservedNumber) || nextNumber > uint64(protowire.LastReservedNumber) {
1718+
break
1719+
}
1720+
}
1721+
nextNumber++
1722+
if nextNumber > uint64(protowire.MaxValidNumber) {
1723+
// Don't suggest numbers beyond the valid range
1724+
return nil
1725+
}
1726+
}
1727+
1728+
next := strconv.FormatUint(nextNumber, 10)
1729+
return []protocol.CompletionItem{
1730+
{
1731+
Label: next,
1732+
Kind: protocol.CompletionItemKindValue,
1733+
InsertText: next,
1734+
},
1735+
}
1736+
}
1737+
16371738
// resolveCompletionItem resolves additional details for a completion item.
16381739
//
16391740
// This function is called by the CompletionResolve handler in server.go.

private/buf/buflsp/completion_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ func TestCompletion(t *testing.T) {
7171
character: uint32(len(" rpc GetUser(Get) returns (Get") - 1),
7272
expectedContains: []string{"GetUserRequest", "GetUserResponse"},
7373
},
74+
{
75+
name: "complete_field_number_after_reserved",
76+
line: 31, // Line with "User user ="
77+
character: 14, // After " User user = "
78+
expectedContains: []string{"6"},
79+
},
80+
{
81+
name: "complete_field_number_after_reserved_with_semicolon",
82+
line: 40, // Line with "User user = ;"
83+
character: 14, // After " User user = "
84+
expectedContains: []string{"6"},
85+
},
86+
{
87+
name: "complete_field_number_skips_protobuf_reserved_range",
88+
line: 46, // Line with "User user = ;"
89+
character: 14, // After " User user = "
90+
expectedContains: []string{"20000"},
91+
},
7492
}
7593

7694
for _, tt := range tests {

private/buf/buflsp/testdata/completion/test.proto

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,27 @@ message GetUserRequest {
2222
message GetUserResponse {
2323
User user = 1;
2424
}
25+
26+
message ReservedFieldTest {
27+
reserved 2;
28+
reserved 3 to 5;
29+
reserved 7 to 10;
30+
31+
string blah = 1;
32+
User user = // complete_field_number_after_reserved
33+
}
34+
35+
message ReservedFieldTestTwo {
36+
reserved 2;
37+
reserved 3 to 5;
38+
reserved 7 to 10;
39+
40+
string blah = 1;
41+
User user = ; // complete_field_number_after_reserved_with_semicolon
42+
}
43+
44+
message ProtobufReservedRangeTest {
45+
reserved 1 to 18999;
46+
47+
User user = ; // complete_field_number_skips_protobuf_reserved_range
48+
}

0 commit comments

Comments
 (0)