all repos

clerk @ f2dd916162e462998dbd1b2fa9d0cc8ce9529226

missing tooling for ledger/hledger
2 files changed, 108 insertions(+), 5 deletions(-)
lsp: completion: replaces whole word
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-09-10 13:33:34 +0300
Authored at: 2026-09-09 14:46:29 +0300
Change ID: pxyzqtvsxnuznqmrxntupurlqotrstup
Parent: 0ff087c
M internal/lsp/textdocument_completion.go
···
        147
        147
         	seen := false

      
        148
        148
         	for _, t := range toks[1:] {

      
        149
        149
         		switch t.Type {

      
        150
        
        -		case token.WHITESPACE, token.STAR, token.BANG, token.DATE, token.TIME,

      
        151
        
        -			token.EQ, token.EQEQ, token.EQEQEQ:

      
        152
        
        -			fieldStart = t.Span.End.Offset

      
        153
        
        -			fieldEnd = t.Span.End.Offset

      
        
        150
        +		case token.WHITESPACE, token.STAR, token.BANG, token.DATE, token.TIME, token.EQ, token.EQEQ, token.EQEQEQ:

      
        
        151
        +			if cursor >= t.Span.End.Offset {

      
        
        152
        +				fieldStart = t.Span.End.Offset

      
        
        153
        +				fieldEnd = t.Span.End.Offset

      
        
        154
        +			}

      
        154
        155
         		case token.TEXT:

      
        155
        156
         			lit := content[t.Span.Start.Offset:t.Span.End.Offset]

      
        156
        157
         			if !seen && len(lit) >= 2 && lit[0] == '(' && lit[len(lit)-1] == ')' {

      ···
        511
        512
         	}

      
        512
        513
         

      
        513
        514
         	end := cursor

      
        514
        
        -	if ctx == cmplDate {

      
        
        515
        +	switch ctx {

      
        
        516
        +	case cmplDate:

      
        515
        517
         		end = dateTokenEnd(content, start)

      
        
        518
        +	case cmplAccount:

      
        
        519
        +		end = fieldTokenEnd(content, end, " \t\r\n;#%)]$=")

      
        
        520
        +	case cmplPayee:

      
        
        521
        +		end = fieldTokenEnd(content, end, " \t\r\n;|")

      
        
        522
        +	case cmplCommodity:

      
        
        523
        +		end = fieldTokenEnd(content, end, " \t\r\n;[]")

      
        
        524
        +	case cmplTagName:

      
        
        525
        +		end = fieldTokenEnd(content, end, " \t\r\n:,;")

      
        
        526
        +	case cmplTagValue:

      
        
        527
        +		end = fieldTokenEnd(content, end, " \t\r\n,;")

      
        
        528
        +	case cmplDirective:

      
        
        529
        +		end = fieldTokenEnd(content, end, " \t\r\n;")

      
        516
        530
         	}

      
        
        531
        +

      
        517
        532
         	items := make([]protocol.CompletionItem, len(ranked))

      
        518
        533
         	for i, r := range ranked {

      
        519
        534
         		// nvim re-filters against the typed prefix, which a transliterated label never starts with; present the typed pattern as filterText.

      ···
        606
        621
         		i++

      
        607
        622
         	}

      
        608
        623
         	return i

      
        
        624
        +}

      
        
        625
        +

      
        
        626
        +func fieldTokenEnd(content string, end int, stops string) int {

      
        
        627
        +	for end < len(content) && !strings.ContainsRune(stops, rune(content[end])) {

      
        
        628
        +		end++

      
        
        629
        +	}

      
        
        630
        +	return end

      
        609
        631
         }

      
        610
        632
         

      
        611
        633
         // lineBounds returns the byte offsets of the line containing cursor

      
M internal/lsp/textdocument_completion_test.go
···
        142
        142
         	}

      
        143
        143
         }

      
        144
        144
         

      
        
        145
        +func TestCompletionEditReplacesWholeWord(t *testing.T) {

      
        
        146
        +	// mid-word completion: accepting replaces the whole typed word, leaving no suffix

      
        
        147
        +	cases := map[string]struct{ journal, cursorLine, item, wantLine string }{

      
        
        148
        +		"account": {

      
        
        149
        +			journal: `account assets:bank

      
        
        150
        +2024-01-15 x

      
        
        151
        +  assets:bank $10

      
        
        152
        +  assets:cash

      
        
        153
        +`,

      
        
        154
        +			cursorLine: "  assets:b^nk $10\n",

      
        
        155
        +			item:       "assets:bank",

      
        
        156
        +			wantLine:   "  assets:bank $10\n",

      
        
        157
        +		},

      
        
        158
        +		"payee": {

      
        
        159
        +			journal: `account assets:cash

      
        
        160
        +2024-01-15 Grocery Store

      
        
        161
        +  assets:cash

      
        
        162
        +

      
        
        163
        +2024-01-15 Grocery

      
        
        164
        +  assets:cash

      
        
        165
        +`,

      
        
        166
        +			cursorLine: "2024-01-15 Groc^ery Store\n",

      
        
        167
        +			item:       "Grocery",

      
        
        168
        +			wantLine:   "2024-01-15 Grocery Store\n",

      
        
        169
        +		},

      
        
        170
        +		"tag value": {

      
        
        171
        +			journal: `account assets:cash

      
        
        172
        +2024-01-15 x

      
        
        173
        +  assets:cash

      
        
        174
        +; client:acme

      
        
        175
        +`,

      
        
        176
        +			cursorLine: "; client:a^cme\n",

      
        
        177
        +			item:       "acme",

      
        
        178
        +			wantLine:   "; client:acme\n",

      
        
        179
        +		},

      
        
        180
        +	}

      
        
        181
        +	for name, tt := range cases {

      
        
        182
        +		t.Run(name, func(t *testing.T) {

      
        
        183
        +			cur := strings.Index(tt.cursorLine, "^") + len(tt.journal)

      
        
        184
        +			content := strings.Replace(tt.journal+tt.cursorLine, "^", "", 1)

      
        
        185
        +			srv := newServer(t).server

      
        
        186
        +			srv.openDoc(uri.URI("file:///t.journal"), content, 1, "journal")

      
        
        187
        +			line, col := lsputil.LineCol(content, cur)

      
        
        188
        +			res, err := srv.Completion(t.Context(), &protocol.CompletionParams{

      
        
        189
        +				TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///t.journal")},

      
        
        190
        +				Position:     protocol.Position{Line: uint32(line), Character: uint32(col)},

      
        
        191
        +			})

      
        
        192
        +			if err != nil {

      
        
        193
        +				t.Fatal(err)

      
        
        194
        +			}

      
        
        195
        +

      
        
        196
        +			list := res.(*protocol.CompletionList)

      
        
        197
        +			var edit *protocol.TextEdit

      
        
        198
        +			for _, it := range list.Items {

      
        
        199
        +				if it.Label == tt.item {

      
        
        200
        +					edit = it.TextEdit.(*protocol.TextEdit)

      
        
        201
        +				}

      
        
        202
        +			}

      
        
        203
        +			if edit == nil {

      
        
        204
        +				t.Fatalf("item %q not offered (got %v)", tt.item, labelsOf(list))

      
        
        205
        +			}

      
        
        206
        +

      
        
        207
        +			s := lsputil.Offset(content, int(edit.Range.Start.Line), int(edit.Range.Start.Character))

      
        
        208
        +			e := lsputil.Offset(content, int(edit.Range.End.Line), int(edit.Range.End.Character))

      
        
        209
        +			got := content[:s] + edit.NewText + content[e:]

      
        
        210
        +			lines := strings.Split(got, "\n")

      
        
        211
        +			if lines[edit.Range.Start.Line] != strings.TrimSuffix(tt.wantLine, "\n") {

      
        
        212
        +				t.Errorf("edited line = %q, want %q", lines[edit.Range.Start.Line], strings.TrimSuffix(tt.wantLine, "\n"))

      
        
        213
        +			}

      
        
        214
        +		})

      
        
        215
        +	}

      
        
        216
        +}

      
        
        217
        +

      
        
        218
        +func labelsOf(list *protocol.CompletionList) []string {

      
        
        219
        +	labels := make([]string, 0, len(list.Items))

      
        
        220
        +	for _, it := range list.Items {

      
        
        221
        +		labels = append(labels, it.Label)

      
        
        222
        +	}

      
        
        223
        +	return labels

      
        
        224
        +}

      
        
        225
        +

      
        145
        226
         func (c cmplCtx) String() string {

      
        146
        227
         	switch c {

      
        147
        228
         	case cmplNone: