2 files changed,
108 insertions(+),
5 deletions(-)
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: