8 files changed,
291 insertions(+),
54 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-17 14:46:05 +0300
Authored at:
2026-08-16 14:34:12 +0300
Change ID:
vlmylqvyntwuwovqrykyswmuvpuosuom
Parent:
b361473
jump to
M
internal/analyzer/analyzer.go
··· 29 29 TagNames []string // unique tag names, sorted 30 30 TagValues []string // unique non-empty tag values across all tags, sorted 31 31 32 - Dates []ast.Date // unique transaction dats in sorted order 33 - DateStrings []string // same order as Dates, "YYYY-MM-DD" 32 + Dates []ast.Date // unique transaction dates, sorted 33 + DateStrings []string // same order as Dates, rendered as written 34 34 35 - TransactionsCountByDate map[string]int // counts transactions per date, keyed by [formatDate] 35 + TransactionsCountByDate map[string]int // counts transactions per date, keyed by [Date.String] 36 36 TransactionsByKey map[string][]*ast.Transaction // groups transactions by [TxDuplicateKey] signature 37 37 } 38 38
M
internal/analyzer/build.go
··· 264 264 if s != "" && !seen[s] { 265 265 seen[s] = true 266 266 a.Dates = append(a.Dates, tx.Date) 267 - a.DateStrings = append(a.DateStrings, s) 268 267 } 269 268 } 270 269 sort.Slice(a.Dates, func(i, j int) bool { 271 270 return a.Dates[i].Compare(a.Dates[j]) < 0 272 271 }) 273 - sort.Strings(a.DateStrings) 272 + a.DateStrings = make([]string, len(a.Dates)) 273 + for i, d := range a.Dates { 274 + a.DateStrings[i] = d.String() 275 + } 274 276 } 275 277 276 278 func (a *Analysis) collectTags() {
M
internal/analyzer/testdata/journal.txtar
··· 134 134 commodity EUR 135 135 136 136 transaction keys (3): 137 - 2024-01-01|groceries|expenses:food,assets:checking, 138 - 2024-01-02|lunch|expenses:food,assets:cash, 139 - 2024-01-04|aws|expenses:food,assets:cash, 137 + 2024/01/01|groceries|expenses:food,assets:checking, 138 + 2024/01/02|lunch|expenses:food,assets:cash, 139 + 2024/01/04|aws|expenses:food,assets:cash, 140 140 141 141 payee templates (3): 142 142 aws
M
internal/lsp/testdata/completion-contexts.txtar
··· 69 69 expenses:f^ood $5 70 70 assets:cash 71 71 72 +in^c 73 +20^ 74 + 75 + 76 +2^024-01-05 Grocery Store 77 + expenses:food $10 78 + assets:cash 79 + 80 +2024-01-02 Coffee Shop 81 + expenses:food $10 82 + assets:cash 83 + 84 +2024-06-15 Grocery Store 85 + expenses:food $10 86 + assets:cash 87 + 88 +2024/01/20 acme 89 + expenses:food $10 90 + assets:cash 91 + 92 +6-10 grocer 93 + expenses:food $10 94 + assets:cash 95 + 96 +2024-06-1^5 Coffee Shop 97 + expenses:food $10 98 + assets:cash 99 + 100 +2024/0^1/20 Grocery Store 101 + expenses:food $10 102 + assets:cash 103 + 72 104 -- expect -- 73 -19:0 directive "" 74 - C 75 - D 76 - N 77 - P 78 - Y 79 - account 80 - alias 81 - apply 82 - commodity 83 - decimal-mark 84 - end 85 - include 86 - payee 87 - tag 88 - year 105 +19:0 date "" 106 + today 107 + yesterday 108 + 2 days ago 109 + 2024-06-15 110 + 2024/01/20 111 + 2024-01-16 112 + 2024-01-15 113 + 2024-01-05 114 + 2024-01-03 115 + 2024-01-02 116 + 2024-01-01 117 + 6-10 89 118 21:4 account "" 90 119 expenses:food 91 120 assets:cash ··· 122 151 client 123 152 35:10 tag "groc" 124 153 37:22 payee "gro" 154 + Grocery Store 125 155 grocer 126 156 grocery 127 157 Grocery 128 - Grocery Store 129 158 38:25 payee "cof" 130 - coffee 131 159 Coffee Shop 160 + coffee 132 161 39:15 payee "gro" 162 + Grocery Store 133 163 grocer 134 164 grocery 135 165 Grocery 136 - Grocery Store 137 166 40:20 none "" 138 167 41:22 none "" 139 168 42:16 tag "cli" 140 169 client 141 -43:10 payee "" 142 - Grocery 143 - Grocery Store 144 - acme 145 - acme|note xy 146 - coffee 147 - grocer 148 - grocery 149 - Coffee Shop 170 +43:10 date "2024-01-03" 171 + 2024-01-03 150 172 46:6 none "" 151 173 51:6 account "no" 152 174 expenses:food 153 175 56:6 none "" 154 176 58:9 none "" 155 177 66:11 payee "" 156 - Grocery 157 178 Grocery Store 179 + Coffee Shop 158 180 acme 181 + grocer 182 + Grocery 159 183 acme|note xy 160 184 coffee 161 - grocer 162 185 grocery 163 - Coffee Shop 164 186 67:14 account "expenses:f" 165 187 expenses:food 188 +70:2 directive "in" 189 + include 190 +71:2 date "20" 191 + today 192 + yesterday 193 + 2 days ago 194 + 2024-06-15 195 + 2024/01/20 196 + 2024-01-16 197 + 2024-01-15 198 + 2024-01-05 199 + 2024-01-03 200 + 2024-01-02 201 + 2024-01-01 202 +74:1 date "2" 203 + today 204 + yesterday 205 + 2 days ago 206 + 2024-06-15 207 + 2024/01/20 208 + 2024-01-16 209 + 2024-01-15 210 + 2024-01-05 211 + 2024-01-03 212 + 2024-01-02 213 + 2024-01-01 214 +94:9 date "2024-06-1" 215 + 2024-06-15 216 +98:6 date "2024/0" 217 + 2024/01/20
M
internal/lsp/textdocument_completion.go
··· 6 6 "math" 7 7 "sort" 8 8 "strings" 9 + "time" 9 10 10 11 "go.lsp.dev/protocol" 11 12 ··· 49 50 cmplAccount 50 51 cmplPayee 51 52 cmplCommodity 53 + cmplDate 52 54 cmplTagName 53 55 cmplTagValue 54 56 cmplDirective ··· 62 64 func detectCompletionCtx(content string, cursor int) (cmplCtx, int) { 63 65 toks := lexLine(content, cursor) 64 66 lineStart, _ := lineBounds(content, cursor) 65 - 67 + if len(toks) == 0 { 68 + return cmplDate, lineStart 69 + } 66 70 if m := commentMarker(toks, cursor); m != -1 { 67 71 return cmplTagContext(content, toks[m].Span.End.Offset, cursor) 68 72 } 69 - if len(toks) == 0 { 70 - return cmplDirective, lineStart 71 - } 72 73 73 74 switch toks[0].Type { 74 75 case token.INDENT: 75 76 return cmplPostingCtx(content, cursor, toks) 76 77 case token.DATE: 77 78 return cmplHeaderCtx(content, cursor, toks) 79 + case token.ILLEGAL: 80 + // a digit that is not lexed yet, as a date: 20, 2026-0 81 + if len(toks[0].Literal) > 0 && isDigitByte(toks[0].Literal[0]) { 82 + return cmplHeaderCtx(content, cursor, toks) 83 + } 84 + return cmplNone, cursor 78 85 case token.ACCOUNT, token.COMMODITY, token.PAYEE, token.TAG: 79 86 return cmplDirectiveContext(cursor, lineStart, toks) 80 87 case token.TEXT: 81 88 return cmplDirective, lineStart // half-typed keyword or unparseable line 89 + default: 90 + return cmplNone, cursor 82 91 } 83 - return cmplNone, cursor 84 92 } 85 93 86 94 func cmplPostingCtx(content string, cursor int, toks []token.Token) (cmplCtx, int) { ··· 126 134 } 127 135 128 136 func cmplHeaderCtx(content string, cursor int, toks []token.Token) (cmplCtx, int) { 137 + // cursor inside date token 138 + if cursor >= toks[0].Span.Start.Offset && cursor <= toks[0].Span.End.Offset { 139 + return cmplDate, toks[0].Span.Start.Offset 140 + } 141 + 129 142 // skip date, status, code, and whitespace - where the payee beginds 130 143 fieldStart := toks[0].Span.End.Offset 131 144 fieldEnd := fieldStart ··· 159 172 seen = true 160 173 } 161 174 fieldEnd = t.Span.End.Offset 162 - default: 163 - // PIPE, SEMICOLON, ... 175 + default: // PIPE, SEMICOLON, ... 164 176 return payeeAt(cursor, fieldStart, fieldEnd) 165 177 } 166 178 } ··· 295 307 score float64 296 308 count int 297 309 lastUsedDays int64 // days since 1970-01-01; 0 when unset 310 + rank int // lower sorts first among equal scores; 0 except for date completions 298 311 } 299 312 300 313 // cmplItems ranks candidates for the content against typed pattern ··· 342 355 for _, name := range directiveKeywords { 343 356 cands = append(cands, cmplCand{label: name}) 344 357 } 358 + case cmplDate: 359 + kind = protocol.CompletionItemKindConstant 360 + now := time.Now() 361 + sep, hasYear := dateStyle(a.DateStrings) 362 + today := renderDate(now, sep, hasYear) 363 + yesterday := renderDate(now.AddDate(0, 0, -1), sep, hasYear) 364 + twoDaysAgo := renderDate(now.AddDate(0, 0, -2), sep, hasYear) 365 + cands = append(cands, 366 + cmplCand{label: today, rank: 0}, 367 + cmplCand{label: yesterday, rank: 1}, 368 + cmplCand{label: twoDaysAgo, rank: 2}) 369 + 370 + seen := map[string]bool{today: true, yesterday: true, twoDaysAgo: true} 371 + for i, d := range a.Dates { 372 + if !datePatternMatch(pattern, a.DateStrings[i]) { 373 + continue 374 + } 375 + label := a.DateStrings[i] 376 + if seen[label] { 377 + continue 378 + } 379 + seen[label] = true 380 + cands = append(cands, cmplCand{label: label, rank: 3, lastUsedDays: dateToDays(d)}) 381 + } 345 382 default: 346 383 return nil 347 384 } ··· 370 407 if ranked[i].score != ranked[j].score { 371 408 return ranked[i].score > ranked[j].score 372 409 } 410 + if ranked[i].rank != ranked[j].rank { 411 + return ranked[i].rank < ranked[j].rank 412 + } 373 413 if ranked[i].lastUsedDays != ranked[j].lastUsedDays { 374 414 return ranked[i].lastUsedDays > ranked[j].lastUsedDays 375 415 } ··· 379 419 ranked = ranked[:maxCompletionItems] 380 420 } 381 421 382 - replace := protocol.Range{ 383 - Start: li.Position(start), 384 - End: li.Position(cursor), 422 + end := cursor 423 + if ctx == cmplDate { 424 + end = dateTokenEnd(content, start) 385 425 } 386 426 items := make([]protocol.CompletionItem, len(ranked)) 387 427 for i, r := range ranked { ··· 391 431 SortText: protocol.NewOptional(fmt.Sprintf("%04d", i)), 392 432 FilterText: protocol.NewOptional(r.label), 393 433 TextEdit: &protocol.TextEdit{ 394 - Range: replace, 434 + Range: protocol.Range{ 435 + Start: li.Position(start), 436 + End: li.Position(end), 437 + }, 395 438 NewText: r.label, 396 439 }, 397 440 } ··· 423 466 doy := (153*mp+2)/5 + d - 1 424 467 doe := yoe*365 + yoe/4 - yoe/100 + doy 425 468 return int64(era)*146097 + int64(doe) - 719468 469 +} 470 + 471 +// dateStyle returns reparator and yesr-ness of the most recent history date. 472 +// Defualts to '-'/true when history is empty. 473 +func dateStyle(history []string) (sep byte, hasYear bool) { 474 + sep, hasYear = '-', true 475 + if n := len(history); n > 0 { 476 + if i := strings.IndexAny(history[n-1], "-/."); i >= 0 { 477 + return history[n-1][i], i == 4 478 + } 479 + } 480 + return 481 +} 482 + 483 +func renderDate(t time.Time, sep byte, hasYear bool) string { 484 + d := ast.Date{Month: int(t.Month()), Day: t.Day(), Sep: sep} 485 + if hasYear { 486 + d.Year = t.Year() 487 + } 488 + return d.String() 489 +} 490 + 491 +func datePatternMatch(pattern, canonical string) bool { 492 + pi, ci := 0, 0 493 + for pi < len(pattern) && ci < len(canonical) { 494 + if pattern[pi] != canonical[ci] { 495 + ci++ 496 + continue 497 + } 498 + pi++ 499 + ci++ 500 + } 501 + return pi == len(pattern) 502 +} 503 + 504 +func isDateSepByte(b byte) bool { return b == '-' || b == '/' || b == '.' } 505 +func isDigitByte(b byte) bool { return b >= '0' && b <= '9' } 506 + 507 +func dateTokenEnd(content string, start int) int { 508 + i := start 509 + for i < len(content) && (isDigitByte(content[i]) || isDateSepByte(content[i])) { 510 + i++ 511 + } 512 + return i 426 513 } 427 514 428 515 // lineBounds returns the byte offsets of the line containing cursor
M
internal/lsp/textdocument_completion_test.go
··· 25 25 } 26 26 } 27 27 28 +func TestDateStyle(t *testing.T) { 29 + tests := map[string]struct { 30 + history []string 31 + sep byte 32 + hasYear bool 33 + }{ 34 + "empty": {nil, '-', true}, 35 + "dash": {[]string{"2024-01-02"}, '-', true}, 36 + "slash": {[]string{"2024/01/20"}, '/', true}, 37 + "yearless": {[]string{"6-10"}, '-', false}, 38 + } 39 + for name, tt := range tests { 40 + t.Run(name, func(t *testing.T) { 41 + sep, hasYear := dateStyle(tt.history) 42 + if sep != tt.sep || hasYear != tt.hasYear { 43 + t.Errorf("dateStyle = %q/%v, want %q/%v", sep, hasYear, tt.sep, tt.hasYear) 44 + } 45 + }) 46 + } 47 +} 48 + 49 +func TestDateTokenEnd(t *testing.T) { 50 + tests := map[string]struct { 51 + content string 52 + start int 53 + want int 54 + }{ 55 + "full date": {"2024-01-02 x", 0, 10}, 56 + "unpadded": {"2024/1/2 x", 0, 8}, 57 + "yearless": {"6-10 x", 0, 4}, 58 + "stops at =": {"2024-01-02=2024-01-03", 0, 10}, 59 + "stops at comment": {"2024-01-02;x", 0, 10}, 60 + "partial trailing sep": {"2024- x", 0, 5}, 61 + "partial mid": {"2024-0 x", 0, 6}, 62 + } 63 + for name, tt := range tests { 64 + t.Run(name, func(t *testing.T) { 65 + if got := dateTokenEnd(tt.content, tt.start); got != tt.want { 66 + t.Errorf("dateTokenEnd = %d, want %d", got, tt.want) 67 + } 68 + }) 69 + } 70 +} 71 + 72 +func TestDatePatternMatch(t *testing.T) { 73 + tests := map[string]struct { 74 + pattern, canonical string 75 + want bool 76 + }{ 77 + "digit subsequence": {"2024-01-0", "2024-01-05", true}, 78 + "same sep": {"2024/0", "2024/01/20", true}, 79 + "other sep rejected": {"2024/0", "2024-01-20", false}, 80 + "yearless": {"6-10", "6-10", true}, 81 + "yearless padded rej": {"06-10", "6-10", false}, 82 + "no match": {"2025", "2024-01-20", false}, 83 + "empty": {"", "2024-01-20", true}, 84 + } 85 + for name, tt := range tests { 86 + t.Run(name, func(t *testing.T) { 87 + if got := datePatternMatch(tt.pattern, tt.canonical); got != tt.want { 88 + t.Errorf("datePatternMatch = %v, want %v", got, tt.want) 89 + } 90 + }) 91 + } 92 +} 93 + 28 94 func TestGolden_Completion(t *testing.T) { 29 95 for _, tt := range []string{"completion-contexts", "completion-journal", "completion-no-transactions"} { 30 96 t.Run(tt, func(t *testing.T) { ··· 46 112 if !ok { 47 113 t.Fatalf("Completion returned %T, want *protocol.CompletionList", res) 48 114 } 115 + now := time.Now() 116 + var sep byte 117 + var hasYear bool 118 + if ctx == cmplDate { 119 + if an := h.srv.analysisFor(h.uri); an != nil { 120 + sep, hasYear = dateStyle(an.DateStrings) 121 + } 122 + } 49 123 for _, item := range list.Items { 50 - fmt.Fprintf(&b, " %s\n", item.Label) 124 + label := item.Label 125 + if ctx == cmplDate { 126 + switch label { 127 + case renderDate(now, sep, hasYear): 128 + label = "today" 129 + case renderDate(now.AddDate(0, 0, -1), sep, hasYear): 130 + label = "yesterday" 131 + case renderDate(now.AddDate(0, 0, -2), sep, hasYear): 132 + label = "2 days ago" 133 + } 134 + } 135 + fmt.Fprintf(&b, " %s\n", label) 51 136 } 52 137 } 53 138 golden.Assert(t, ar, b.String()) ··· 69 154 return "tag" 70 155 case cmplTagValue: 71 156 return "tag-value" 157 + case cmplDate: 158 + return "date" 72 159 case cmplDirective: 73 160 return "directive" 74 161 default: ··· 92 179 "1k txns, account": strings.Index(content, "\n 1:2:3 ") + len("\n ") + 2, 93 180 "1k txns, empty payee": strings.Index(content, "transaction 1") + len("transaction "), 94 181 "1k txns, commodity": strings.Index(content, "2 B @@") + len("2 B"), 182 + "1k txns, date": strings.Index(content, "2000-01-0") + len("2000-01-0"), 95 183 } { 96 184 b.Run(tname, func(b *testing.B) { 97 185 line, col := lsputil.LineCol(content, tt)
M
journal/ast/ast.go
··· 56 56 return 0 57 57 } 58 58 59 -// String renders d as "YYYY-MM-DD", or "" for an unset or invalid date. 59 +// String renders d as written: with its own separator, and without the year 60 +// when the date carried none. Returns "" for an unset or invalid date. 60 61 func (d Date) String() string { 61 62 if (d.Year == 0 && d.Month == 0 && d.Day == 0) || 62 63 (d.Month < 1 || d.Month > 12 || d.Day < 1 || d.Day > 31) { 63 64 return "" 64 65 } 65 - return fmt.Sprintf("%04d-%02d-%02d", d.Year, d.Month, d.Day) 66 + sep := d.Sep 67 + if sep == 0 { 68 + sep = '-' 69 + } 70 + if d.Year == 0 { 71 + return fmt.Sprintf("%d%c%d", d.Month, sep, d.Day) 72 + } 73 + return fmt.Sprintf("%04d%c%02d%c%02d", d.Year, sep, d.Month, sep, d.Day) 66 74 } 67 75 68 76 type Time struct {