@@ -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".
15011527func 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.
15201551func 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.
15961641func 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.
0 commit comments