30 files changed,
667 insertions(+),
131 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-06 16:47:26 +0300
Authored at:
2026-08-05 15:12:14 +0300
Change ID:
pozmovzsonumnprqqvwtlqumvkkzplvy
Parent:
9b2ce6b
jump to
M
internal/analyzer/analyzer.go
··· 24 24 // PayeeTemplates holds the last transaction's postings per payee name. 25 25 PayeeTemplates map[string][]PostingTemplate 26 26 27 - TagNames []string // unique, from tag directives 27 + Tags map[string]*TagInfo 28 + TagNames []string // unique tag names, sorted 29 + TagValues []string // unique non-empty tag values across all tags, sorted 28 30 29 31 Dates []ast.Date // unique transaction dats in sorted order 30 32 DateStrings []string // same order as Dates, "YYYY-MM-DD" ··· 70 72 type PayeeUsage struct { 71 73 FileIndex int 72 74 Payee *ast.Payee 75 +} 76 + 77 +type TagInfo struct { 78 + Directives []*ast.TagDirective 79 + Usage []TagUsage 80 + UsedCount int 81 + LastUsed ast.Date 82 + Values []string // unique non-empty values from comment usages, sorted 83 +} 84 + 85 +type TagUsage struct { 86 + FileIndex int 87 + Tag *ast.Tag 73 88 } 74 89 75 90 type PostingTemplate struct {
M
internal/analyzer/analyzer_test.go
··· 238 238 fmt.Fprintf(w, " %d-%02d-%02d\n", d.Year, d.Month, d.Day) 239 239 } 240 240 241 + // tags 242 + tnames := make([]string, 0, len(a.Tags)) 243 + for name := range a.Tags { 244 + tnames = append(tnames, name) 245 + } 246 + sort.Strings(tnames) 247 + 248 + fmt.Fprintf(w, "\ntags (%d):\n", len(a.Tags)) 249 + for _, name := range tnames { 250 + info := a.Tags[name] 251 + fmt.Fprintf(w, " %s\n", name) 252 + fmt.Fprintf(w, " directives: %d\n", len(info.Directives)) 253 + fmt.Fprintf(w, " used: %d\n", info.UsedCount) 254 + for _, d := range info.Directives { 255 + fmt.Fprintf(w, " tag %s\n", d.Name) 256 + } 257 + fmt.Fprintf(w, " usages: %d\n", len(info.Usage)) 258 + for _, u := range info.Usage { 259 + fmt.Fprintf(w, " file %d: %s=%q\n", u.FileIndex, u.Tag.Key, u.Tag.Value) 260 + } 261 + if len(info.Values) > 0 { 262 + fmt.Fprintf(w, " values: %s\n", strings.Join(info.Values, " ")) 263 + } 264 + if info.LastUsed.Year != 0 { 265 + fmt.Fprintf(w, " last-used: %d-%02d-%02d\n", info.LastUsed.Year, info.LastUsed.Month, info.LastUsed.Day) 266 + } 267 + } 268 + 241 269 // tag names 242 270 fmt.Fprintf(w, "\ntag names (%d):\n", len(a.TagNames)) 243 271 for _, t := range a.TagNames { 244 272 fmt.Fprintf(w, " %s\n", t) 273 + } 274 + 275 + // tag values 276 + fmt.Fprintf(w, "\ntag values (%d):\n", len(a.TagValues)) 277 + for _, v := range a.TagValues { 278 + fmt.Fprintf(w, " %s\n", v) 245 279 } 246 280 247 281 // commodity decimal marks
M
internal/analyzer/build.go
··· 21 21 Accounts: make(map[string]*AccountInfo), 22 22 Commodities: make(map[string]*CommodityInfo), 23 23 Payees: make(map[string]*PayeeInfo), 24 + Tags: make(map[string]*TagInfo), 24 25 AccountsByPrefix: make(map[string][]string), 25 26 PayeeTemplates: make(map[string][]PostingTemplate), 26 27 CommodityDecimalMarks: make(map[string]byte), ··· 37 38 a.sortAccountNames() 38 39 a.collectPayeeNames() 39 40 a.collectDates() 40 - a.collectTagNames() 41 + a.collectTags() 41 42 return a 42 43 } 43 44 ··· 63 64 a.addCommodityDirective(e) 64 65 case *ast.PayeeDirective: 65 66 a.addPayeeDirective(e) 67 + case *ast.TagDirective: 68 + a.addTagDirective(e) 69 + case *ast.Comment: 70 + a.addCommentTags(fileIndex, nil, e) 66 71 case *ast.Transaction: 67 72 a.Transactions = append(a.Transactions, e) 68 73 a.addPostings(fileIndex, e.Postings, &e.Date) 69 74 a.addPayee(fileIndex, e.Payee) 70 75 a.addPayeeTemplate(e) 76 + a.addCommentTags(fileIndex, &e.Date, e.Comment) 77 + for _, c := range e.HeaderComments { 78 + a.addCommentTags(fileIndex, &e.Date, c) 79 + } 71 80 key := TxDuplicateKey(e) 72 81 a.TransactionsByKey[key] = append(a.TransactionsByKey[key], e) 73 82 case *ast.PeriodicTransaction: 74 83 a.PeriodicTransactions = append(a.PeriodicTransactions, e) 75 84 a.addPostings(fileIndex, e.Postings, nil) 85 + a.addCommentTags(fileIndex, nil, e.Comment) 86 + for _, c := range e.HeaderComments { 87 + a.addCommentTags(fileIndex, nil, c) 88 + } 76 89 case *ast.AutomatedTransaction: 77 90 a.AutomatedTransactions = append(a.AutomatedTransactions, e) 78 91 a.addPostings(fileIndex, e.Postings, nil) 79 - case *ast.DecimalMarkDirective: 80 - // Collect commodity decimal marks from directives with explicit format, 81 - // but DecimalMarkDirective sets the default for all commodities. 82 - // We don't track a "default" — individual commodities get theirs from amount formatting. 92 + a.addCommentTags(fileIndex, nil, e.Comment) 93 + for _, c := range e.HeaderComments { 94 + a.addCommentTags(fileIndex, nil, c) 95 + } 83 96 case *ast.DefaultCommodityDirective: 84 97 if e.Amount.Commodity != "" { 85 98 mark := e.Amount.QuantityFmt.Decimal ··· 120 133 }) 121 134 } 122 135 136 +func (a *Analysis) addTagDirective(td *ast.TagDirective) { 137 + if td.Name == "" { 138 + return 139 + } 140 + info, ok := a.Tags[td.Name] 141 + if !ok { 142 + info = &TagInfo{} 143 + a.Tags[td.Name] = info 144 + } 145 + info.Directives = append(info.Directives, td) 146 +} 147 + 148 +func (a *Analysis) addCommentTags(fileIndex int, date *ast.Date, c *ast.Comment) { 149 + if c == nil { 150 + return 151 + } 152 + for i := range c.Tags { 153 + t := &c.Tags[i] 154 + info, ok := a.Tags[t.Key] 155 + if !ok { 156 + info = &TagInfo{} 157 + a.Tags[t.Key] = info 158 + } 159 + info.Usage = append(info.Usage, TagUsage{FileIndex: fileIndex, Tag: t}) 160 + info.UsedCount++ 161 + if date != nil { 162 + info.LastUsed = maxDate(info.LastUsed, *date) 163 + } 164 + } 165 +} 166 + 123 167 func (a *Analysis) addCommodityDirective(cd *ast.CommodityDirective) { 124 168 info, ok := a.Commodities[cd.Commodity] 125 169 if !ok { ··· 171 215 if posting.Balance.Cost != nil { 172 216 a.addCommodityUsage(fileIndex, &posting.Balance.Cost.Amount, date) 173 217 } 218 + } 219 + 220 + a.addCommentTags(fileIndex, date, posting.Comment) 221 + for i := range posting.Comments { 222 + a.addCommentTags(fileIndex, date, &posting.Comments[i]) 174 223 } 175 224 176 225 // Collect decimal mark from amount formatting. ··· 222 271 sort.Strings(a.DateStrings) 223 272 } 224 273 225 -func (a *Analysis) collectTagNames() { 226 - seen := make(map[string]bool) 227 - for _, d := range a.Directives { 228 - if td, ok := d.(*ast.TagDirective); ok && td.Name != "" && !seen[td.Name] { 229 - seen[td.Name] = true 230 - a.TagNames = append(a.TagNames, td.Name) 274 +func (a *Analysis) collectTags() { 275 + names := make([]string, 0, len(a.Tags)) 276 + values := make(map[string]bool) 277 + for name, info := range a.Tags { 278 + names = append(names, name) 279 + seen := make(map[string]bool) 280 + for _, u := range info.Usage { 281 + if u.Tag.Value == "" { 282 + continue 283 + } 284 + seen[u.Tag.Value] = true 285 + values[u.Tag.Value] = true 286 + } 287 + if len(seen) > 0 { 288 + info.Values = sortedKeys(seen) 231 289 } 232 290 } 233 - sort.Strings(a.TagNames) 291 + sort.Strings(names) 292 + a.TagNames = names 293 + a.TagValues = sortedKeys(values) 294 +} 295 + 296 +func sortedKeys(m map[string]bool) []string { 297 + out := make([]string, 0, len(m)) 298 + for k := range m { 299 + out = append(out, k) 300 + } 301 + sort.Strings(out) 302 + return out 234 303 } 235 304 236 305 func formatDate(d ast.Date) string {
M
internal/lsp/testdata/completion-journal.txtar
··· 18 18 19 19 ; client:acme 20 20 ; project:alpha 21 +; client:a^cme 22 +; project:^ 21 23 22 24 2024-01-16 ^ 23 25 expenses:f^ood $5 ··· 39 41 acc^ount 40 42 41 43 -- expect -- 42 -20:11 payee "" 44 +19:10 tag-value "a" 45 + acme 46 +20:10 tag-value "" 47 + alpha 48 +22:11 payee "" 43 49 Grocery 44 50 Store 45 -21:12 account "expenses:f" 51 +23:12 account "expenses:f" 46 52 expenses:food 47 -26:16 account "assets:c" 53 +28:16 account "assets:c" 48 54 assets:cash 49 -28:11 commodity "U" 55 +30:11 commodity "U" 50 56 USD 51 -30:8 payee "Gr" 57 +32:8 payee "Gr" 52 58 Grocery 53 -32:7 tag "cli" 59 +34:7 tag "cli" 54 60 client 55 -34:5 tag "cli" 61 +36:5 tag "cli" 56 62 client 57 -35:6 tag "proj" 58 -37:3 directive "acc" 63 +37:6 tag "proj" 64 + project 65 +39:3 directive "acc" 59 66 account
M
internal/lsp/testdata/semantic-journal.txtar
··· 117 117 b 10 @@ 5 USD 118 118 c $-5.00 @ 0.9 BTC 119 119 120 +2024-01-22 Business dinner ; client:acme, project:alpha 121 + expenses:meals $50 ; billable: 122 + assets:cash ; date:2024-01-16 123 + 124 +; tag:value, trailing text 125 + 120 126 -- expect -- 121 127 0:0+12 comment 122 128 1:0+2 comment ··· 389 395 116:12+1 operator 390 396 116:14+3 number 391 397 116:18+3 type 398 +118:0+10 class 399 +118:11+15 property 400 +118:28+2 comment 401 +118:30+11 property 402 +118:41+2 comment 403 +118:43+13 property 404 +119:4+14 namespace 405 +119:22+1 type 406 +119:23+2 number 407 +119:27+2 comment 408 +119:29+9 property 409 +120:4+11 namespace 410 +120:27+2 comment 411 +120:29+15 property 412 +122:0+2 comment 413 +122:2+9 property 414 +122:11+16 comment
M
internal/lsp/textdocument_completion.go
··· 51 51 cmplPayee 52 52 cmplCommodity 53 53 cmplTagName 54 + cmplTagValue 54 55 cmplDirective 55 56 ) 56 57 ··· 214 215 return cmplNone, cursor 215 216 } 216 217 217 -// cmplTagContext completes tag names at the start of a comment, stopping at first ':'. 218 +// commentStart completes tag names before ':' of the current tag and tag values after it 218 219 func cmplTagContext(content string, commentStart, cursor int) (cmplCtx, int) { 219 - if strings.ContainsRune(content[commentStart:cursor], ':') { 220 - return cmplNone, cursor 220 + prefix := content[commentStart:cursor] 221 + segStart := commentStart 222 + seg := prefix 223 + if comma := strings.LastIndexByte(prefix, ','); comma >= 0 { 224 + segStart = commentStart + comma + 1 225 + seg = prefix[comma+1:] 221 226 } 222 - keyStart := commentStart + lastSeparator(content[commentStart:cursor]) + 1 227 + if colon := strings.IndexByte(seg, ':'); colon >= 0 { 228 + start := segStart + colon + 1 229 + for start < cursor && (content[start] == ' ' || content[start] == '\t') { 230 + start++ 231 + } 232 + return cmplTagValue, start 233 + } 234 + keyStart := commentStart + lastSeparator(prefix) + 1 223 235 return cmplTagName, keyStart 236 +} 237 + 238 +// tagKeyAt returns the key of tag whose value region starts at start 239 +func tagKeyAt(content string, start int) (string, bool) { 240 + lineStart, _ := lineBounds(content, start) 241 + segStart := lineStart 242 + for i := start - 1; i >= lineStart; i-- { 243 + switch content[i] { 244 + case ',', ';', '#', '%': 245 + segStart = i + 1 246 + i = lineStart - 1 // stop at the separator closest to start 247 + } 248 + } 249 + colon := strings.IndexByte(content[segStart:start], ':') 250 + if colon < 0 { 251 + return "", false 252 + } 253 + colon += segStart 254 + keyStart := lastSeparator(content[segStart:colon]) + 1 255 + key := content[segStart+keyStart : colon] 256 + if key == "" { 257 + return "", false 258 + } 259 + return key, true 224 260 } 225 261 226 262 // commentMarker returns index of the first comment marker token at or before the cursor, or -1. ··· 286 322 } 287 323 case cmplTagName: 288 324 kind = protocol.CompletionItemKindProperty 289 - for _, name := range a.TagNames { 290 - cands = append(cands, cmplCand{label: name}) 325 + for name, info := range a.Tags { 326 + cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsed: info.LastUsed}) 327 + } 328 + case cmplTagValue: 329 + kind = protocol.CompletionItemKindProperty 330 + if key, ok := tagKeyAt(content, start); ok { 331 + if info, ok := a.Tags[key]; ok { 332 + for _, v := range info.Values { 333 + cands = append(cands, cmplCand{label: v}) 334 + } 335 + } 291 336 } 292 337 case cmplDirective: 293 338 kind = protocol.CompletionItemKindKeyword
M
internal/lsp/textdocument_completion_test.go
··· 16 16 ) 17 17 18 18 func TestDetectCompletionCtx(t *testing.T) { 19 - tests := []struct { 20 - name string 21 - ctx cmplCtx 22 - in string 23 - want string 19 + tests := map[string]struct { 20 + ctx cmplCtx 21 + in, want string 24 22 }{ 25 - {"posting account", cmplAccount, " expenses:f^ood $50\n", "expenses:f"}, 26 - {"posting account empty", cmplAccount, " ^\n", ""}, 27 - {"posting account after colon", cmplAccount, " expenses:^food $50\n", "expenses:"}, 28 - {"posting amount commodity", cmplCommodity, " expenses:food $^50\n", "$"}, 29 - {"posting empty amount region", cmplCommodity, " expenses:food ^\n", ""}, 30 - {"posting commodity word", cmplCommodity, " expenses:food U^SD\n", "U"}, 31 - {"posting amount number", cmplNone, " expenses:food $5^0\n", ""}, 32 - {"posting cost quantity", cmplNone, " expenses:food $50 @^ 1.5\n", ""}, 33 - {"posting status", cmplAccount, " * expenses:f^ood $50\n", "expenses:f"}, 34 - {"posting virtual", cmplAccount, " (expenses:f^ood) $50\n", "expenses:f"}, 35 - {"posting comment tag", cmplTagName, " expenses:food ; clie^nt:x\n", "clie"}, 36 - {"posting comment tag value", cmplNone, " expenses:food ; client:^x\n", ""}, 37 - {"header payee", cmplPayee, "2024-01-15 acm^e\n assets:cash\n", "acm"}, 38 - {"header payee empty", cmplPayee, "2024-01-15 ^\n", ""}, 39 - {"header payee right after date", cmplPayee, "2024-01-15^\n", ""}, 40 - {"header status and code", cmplPayee, "2024-01-15 * (123) gro^cer\n", "gro"}, 41 - {"header second date", cmplPayee, "2024-01-15=2024-01-16 acm^e\n", "acm"}, 42 - {"header quoted payee", cmplPayee, "2024-01-15 \"ac^me\"\n", "ac"}, 43 - {"header pipe note", cmplNone, "2024-01-15 acme | no^te\n", ""}, 44 - {"header pipe inline", cmplNone, "2024-01-15 acme|note x^y\n", ""}, 45 - {"header inline comment", cmplTagName, "2024-01-15 ; foo^", "foo"}, 46 - {"directive keyword partial", cmplDirective, "acc^ount expenses\n", "acc"}, 47 - {"directive keyword empty line", cmplDirective, "\n^", ""}, 48 - {"account directive value", cmplAccount, "account exp^enses\n", "exp"}, 49 - {"commodity directive value", cmplCommodity, "commodity U^SD\n", "U"}, 50 - {"payee directive value", cmplPayee, "payee ac^me\n", "ac"}, 51 - {"tag directive value", cmplTagName, "tag pro^ject\n", "pro"}, 52 - {"comment tag", cmplTagName, "; clie^nt:x\n", "clie"}, 53 - {"comment value", cmplNone, "; client:x^yz\n", ""}, 54 - {"comment plain text", cmplTagName, "; groc^eries\n", "groc"}, 55 - {"subdirective ignored", cmplNone, "account expenses\n no^te ignore\n", ""}, 56 - {"periodic header", cmplNone, "~ monthly^ budget\n", ""}, 57 - {"cjk posting", cmplAccount, " 支出:食^物 50\n", "支出:食"}, 58 - {"ukrainian payee", cmplPayee, "2024-01-15 прод^укти\n", "прод"}, 59 - {"crlf posting", cmplAccount, "2024-01-15 x\r\n expenses:f^ood $50\r\n", "expenses:f"}, 60 - {"crlf header", cmplPayee, "2024-01-15 acm^e\r\n assets:cash\r\n", "acm"}, 23 + "posting account": {cmplAccount, " expenses:f^ood $50\n", "expenses:f"}, 24 + "posting account empty": {cmplAccount, " ^\n", ""}, 25 + "posting account after colon": {cmplAccount, " expenses:^food $50\n", "expenses:"}, 26 + "posting amount commodity": {cmplCommodity, " expenses:food $^50\n", "$"}, 27 + "posting empty amount region": {cmplCommodity, " expenses:food ^\n", ""}, 28 + "posting commodity word": {cmplCommodity, " expenses:food U^SD\n", "U"}, 29 + "posting amount number": {cmplNone, " expenses:food $5^0\n", ""}, 30 + "posting cost quantity": {cmplNone, " expenses:food $50 @^ 1.5\n", ""}, 31 + "posting status": {cmplAccount, " * expenses:f^ood $50\n", "expenses:f"}, 32 + "posting virtual": {cmplAccount, " (expenses:f^ood) $50\n", "expenses:f"}, 33 + "posting comment tag": {cmplTagName, " expenses:food ; clie^nt:x\n", "clie"}, 34 + "posting comment tag value": {cmplTagValue, " expenses:food ; client:^x\n", ""}, 35 + "header payee": {cmplPayee, "2024-01-15 acm^e\n assets:cash\n", "acm"}, 36 + "header payee empty": {cmplPayee, "2024-01-15 ^\n", ""}, 37 + "header payee right after date": {cmplPayee, "2024-01-15^\n", ""}, 38 + "header status and code": {cmplPayee, "2024-01-15 * (123) gro^cer\n", "gro"}, 39 + "header second date": {cmplPayee, "2024-01-15=2024-01-16 acm^e\n", "acm"}, 40 + "header quoted payee": {cmplPayee, "2024-01-15 \"ac^me\"\n", "ac"}, 41 + "header pipe note": {cmplNone, "2024-01-15 acme | no^te\n", ""}, 42 + "header pipe inline": {cmplNone, "2024-01-15 acme|note x^y\n", ""}, 43 + "header inline comment": {cmplTagName, "2024-01-15 ; foo^", "foo"}, 44 + "directive keyword partial": {cmplDirective, "acc^ount expenses\n", "acc"}, 45 + "directive keyword empty line": {cmplDirective, "\n^", ""}, 46 + "account directive value": {cmplAccount, "account exp^enses\n", "exp"}, 47 + "commodity directive value": {cmplCommodity, "commodity U^SD\n", "U"}, 48 + "payee directive value": {cmplPayee, "payee ac^me\n", "ac"}, 49 + "tag directive value": {cmplTagName, "tag pro^ject\n", "pro"}, 50 + "comment tag": {cmplTagName, "; clie^nt:x\n", "clie"}, 51 + "comment value": {cmplTagValue, "; client:x^yz\n", "x"}, 52 + "comment value empty": {cmplTagValue, "; client:^\n", ""}, 53 + "comment value after space": {cmplTagValue, "; client: ac^me\n", "ac"}, 54 + "comment value second tag": {cmplTagValue, "; a:b, client:x^\n", "x"}, 55 + "comment value with colon": {cmplTagValue, "; url:https://example^\n", "https://example"}, 56 + "comment key after previous value": {cmplTagName, "; a:b:c, cli^ent:x\n", "cli"}, 57 + "comment plain text": {cmplTagName, "; groc^eries\n", "groc"}, 58 + "subdirective ignored": {cmplNone, "account expenses\n no^te ignore\n", ""}, 59 + "periodic header": {cmplNone, "~ monthly^ budget\n", ""}, 60 + "cjk posting": {cmplAccount, " 支出:食^物 50\n", "支出:食"}, 61 + "ukrainian payee": {cmplPayee, "2024-01-15 прод^укти\n", "прод"}, 62 + "crlf posting": {cmplAccount, "2024-01-15 x\r\n expenses:f^ood $50\r\n", "expenses:f"}, 63 + "crlf header": {cmplPayee, "2024-01-15 acm^e\r\n assets:cash\r\n", "acm"}, 61 64 } 62 - for _, tt := range tests { 63 - t.Run(tt.name, func(t *testing.T) { 65 + for tname, tt := range tests { 66 + t.Run(tname, func(t *testing.T) { 64 67 cont := tt.in 65 68 i := strings.Index(cont, "^") 66 69 if i < 0 { ··· 93 96 return "commodity" 94 97 case cmplTagName: 95 98 return "tag" 99 + case cmplTagValue: 100 + return "tag-value" 96 101 case cmplDirective: 97 102 return "directive" 98 103 default:
M
internal/lsp/textdocument_semantic.go
··· 192 192 return 1 193 193 } 194 194 195 -func visitEntry(content string, e ast.Entry, emit semEmitFn) { 195 +func visitEntry(content string, e ast.Entry, emit semEmitFunc) { 196 196 switch e := e.(type) { 197 197 case *ast.Transaction: 198 198 visitTransaction(content, e, emit) ··· 203 203 case *ast.AccountDirective: 204 204 emit(directiveKeyword(e.Span, "account"), semDirective, 0) 205 205 emit(e.Account.Span, semAccount, 0) 206 - if e.Comment != nil { 207 - emit(e.Comment.Span, semComment, 0) 208 - } 206 + emitComment(e.Comment, emit) 209 207 case *ast.CommodityDirective: 210 208 emit(directiveKeyword(e.Span, "commodity"), semDirective, 0) 211 209 if e.Format.Span.End.Offset > 0 { ··· 213 211 } else if e.CommoditySpan.Start.Offset > 0 && e.CommoditySpan.End.Offset > 0 { 214 212 emit(e.CommoditySpan, semCommodity, 0) 215 213 } 216 - if e.Comment != nil { 217 - emit(e.Comment.Span, semComment, 0) 218 - } 214 + emitComment(e.Comment, emit) 219 215 case *ast.IncludeDirective: 220 216 emitDirective(content, e.Span, len("include"), semString, e.Comment, emit) 221 217 case *ast.PayeeDirective: ··· 229 225 emit(op, semOperator, 0) 230 226 } 231 227 emit(e.To.Span, semAccount, 0) 232 - if e.Comment != nil { 233 - emit(e.Comment.Span, semComment, 0) 234 - } 228 + emitComment(e.Comment, emit) 235 229 case *ast.YearDirective: 236 230 kwLen := len("year") 237 231 if content[e.Span.Start.Offset] == 'Y' { ··· 243 237 case *ast.DefaultCommodityDirective: 244 238 emit(directiveKeyword(e.Span, "D"), semDirective, 0) 245 239 semEmitAmount(content, &e.Amount, emit) 246 - if e.Comment != nil { 247 - emit(e.Comment.Span, semComment, 0) 248 - } 240 + emitComment(e.Comment, emit) 249 241 case *ast.MarketPriceDirective: 250 242 emit(directiveKeyword(e.Span, "P"), semDirective, 0) 251 243 emit(e.DateTime.Date.Span, semDate, 0) ··· 261 253 emit(comm, semCommodity, 0) 262 254 } 263 255 semEmitAmount(content, &e.Amount, emit) 264 - if e.Comment != nil { 265 - emit(e.Comment.Span, semComment, 0) 266 - } 256 + emitComment(e.Comment, emit) 267 257 case *ast.ConversionDirective: 268 258 emit(directiveKeyword(e.Span, "C"), semDirective, 0) 269 259 semEmitAmount(content, &e.From, emit) ··· 272 262 emit(op, semOperator, 0) 273 263 } 274 264 semEmitAmount(content, &e.To, emit) 275 - if e.Comment != nil { 276 - emit(e.Comment.Span, semComment, 0) 277 - } 265 + emitComment(e.Comment, emit) 278 266 case *ast.Comment: 279 - emit(e.Span, semComment, 0) 267 + emitComment(e, emit) 280 268 case *ast.CommentBlockDirective: 281 269 emit(e.Span, semComment, 0) 282 270 case *ast.IgnoredDirective: ··· 289 277 } 290 278 } 291 279 292 -func visitTransaction(content string, t *ast.Transaction, emit semEmitFn) { 280 +func visitTransaction(content string, t *ast.Transaction, emit semEmitFunc) { 293 281 emit(t.Date.Span, semDate, 0) 294 282 if t.SecondDate != nil { 295 283 emit(t.SecondDate.Span, semDate, 0) ··· 306 294 if t.Note != nil { 307 295 emit(t.Note.Span, semProperty, 0) 308 296 } 309 - if t.Comment != nil { 310 - emit(t.Comment.Span, semComment, 0) 311 - } 297 + emitComment(t.Comment, emit) 312 298 for i := range t.HeaderComments { 313 - emit(t.HeaderComments[i].Span, semComment, 0) 299 + emitComment(t.HeaderComments[i], emit) 314 300 } 315 301 for _, p := range t.Postings { 316 302 visitPosting(content, p, emit) 317 303 } 318 304 } 319 305 320 -func visitPeriodicTransaction(content string, pt *ast.PeriodicTransaction, emit semEmitFn) { 306 +func visitPeriodicTransaction(content string, pt *ast.PeriodicTransaction, emit semEmitFunc) { 321 307 // ~ operator is at the start of the period span 322 308 emit(offsetSpan(pt.Span.Start.File, pt.Span.Start.Offset, pt.Span.Start.Offset+1), semOperator, 0) 323 309 ··· 345 331 if pt.Description != nil { 346 332 emit(pt.Description.Span, semProperty, 0) 347 333 } 348 - if pt.Comment != nil { 349 - emit(pt.Comment.Span, semComment, 0) 350 - } 334 + emitComment(pt.Comment, emit) 351 335 for i := range pt.HeaderComments { 352 - emit(pt.HeaderComments[i].Span, semComment, 0) 336 + emitComment(pt.HeaderComments[i], emit) 353 337 } 354 338 for _, p := range pt.Postings { 355 339 visitPosting(content, p, emit) 356 340 } 357 341 } 358 342 359 -func visitAutomatedTransaction(content string, at *ast.AutomatedTransaction, emit semEmitFn) { 343 +func visitAutomatedTransaction(content string, at *ast.AutomatedTransaction, emit semEmitFunc) { 360 344 // = operator is at the start of the expression span 361 345 emit(offsetSpan(at.Span.Start.File, at.Span.Start.Offset, at.Span.Start.Offset+1), semOperator, 0) 362 346 363 347 if at.Expr.Value != "" { 364 348 emit(at.Expr.Span, semString, 0) 365 349 } 366 - if at.Comment != nil { 367 - emit(at.Comment.Span, semComment, 0) 368 - } 350 + emitComment(at.Comment, emit) 369 351 for i := range at.HeaderComments { 370 - emit(at.HeaderComments[i].Span, semComment, 0) 352 + emitComment(at.HeaderComments[i], emit) 371 353 } 372 354 for _, p := range at.Postings { 373 355 visitPosting(content, p, emit) 374 356 } 375 357 } 376 358 377 -func visitPosting(content string, p *ast.Posting, emit semEmitFn) { 359 +func visitPosting(content string, p *ast.Posting, emit semEmitFunc) { 378 360 if p.Status.Value != ast.StatusNone { 379 361 emit(p.Status.Span, semStatus, 0) 380 362 } ··· 410 392 if p.Balance != nil { 411 393 semEmitBalanceAssertion(content, p.Balance, emit) 412 394 } 413 - if p.Comment != nil { 414 - emit(p.Comment.Span, semComment, 0) 415 - } 395 + emitComment(p.Comment, emit) 416 396 for i := range p.Comments { 417 - emit(p.Comments[i].Span, semComment, 0) 397 + emitComment(&p.Comments[i], emit) 418 398 } 419 399 } 420 400 ··· 433 413 return betweenSpan(content, e.Start.File, kwEnd, end) 434 414 } 435 415 436 -func semEmitAmount(content string, a *ast.Amount, emit semEmitFn) { 416 +func semEmitAmount(content string, a *ast.Amount, emit semEmitFunc) { 437 417 if a == nil { 438 418 return 439 419 } ··· 449 429 } 450 430 } 451 431 452 -func semEmitQuantity(content string, a *ast.Amount, emit semEmitFn) { 432 +func semEmitQuantity(content string, a *ast.Amount, emit semEmitFunc) { 453 433 qStart, qEnd := quantitySpan(content, a) 454 434 if qEnd <= qStart { 455 435 return ··· 482 462 return start, end 483 463 } 484 464 485 -type semEmitFn func(token.Span, uint32, uint32) 465 +type semEmitFunc func(tok token.Span, tokKind, modifier uint32) 466 + 467 +func emitComment(c *ast.Comment, emit semEmitFunc) { 468 + if c == nil { 469 + return 470 + } 471 + if len(c.Tags) == 0 { 472 + emit(c.Span, semComment, 0) 473 + return 474 + } 475 + pos := c.Span.Start.Offset 476 + for _, t := range c.Tags { 477 + if t.Span.Start.Offset > pos { 478 + emit(offsetSpan(c.Span.Start.File, pos, t.Span.Start.Offset), semComment, 0) 479 + } 480 + emit(t.Span, semProperty, 0) 481 + pos = t.Span.End.Offset 482 + } 483 + if pos < c.Span.End.Offset { 484 + emit(offsetSpan(c.Span.Start.File, pos, c.Span.End.Offset), semComment, 0) 485 + } 486 +} 486 487 487 -func emitDirective(content string, e token.Span, kwLen int, valType uint32, comment *ast.Comment, emit semEmitFn) { 488 +func emitDirective(content string, e token.Span, kwLen int, valType uint32, comment *ast.Comment, emit semEmitFunc) { 488 489 kwEnd := e.Start.Offset + kwLen 489 490 emit(token.Span{Start: e.Start, End: offsetPos(e.Start.File, kwEnd)}, semDirective, 0) 490 491 if v, ok := directiveValue(content, e, comment, kwEnd); ok { 491 492 emit(v, valType, 0) 492 493 } 493 - if comment != nil { 494 - emit(comment.Span, semComment, 0) 495 - } 494 + emitComment(comment, emit) 496 495 } 497 496 498 -func semEmitCost(content string, c *ast.Cost, emit semEmitFn) { 497 +func semEmitCost(content string, c *ast.Cost, emit semEmitFunc) { 499 498 if c.IsTotal { 500 499 emit(token.Span{Start: c.Span.Start, End: offsetPos(c.Span.Start.File, c.Span.Start.Offset+2)}, semOperator, 0) 501 500 } else { ··· 504 503 semEmitAmount(content, &c.Amount, emit) 505 504 } 506 505 507 -func semEmitBalanceAssertion(content string, ba *ast.BalanceAssertion, emit semEmitFn) { 506 +func semEmitBalanceAssertion(content string, ba *ast.BalanceAssertion, emit semEmitFunc) { 508 507 // The operator is the run of '=', ':', '*' chars from the span start. 509 508 // The ':' of ':=' precedes the '=' token, so back up one offset. 510 509 opStart := ba.Span.Start.Offset ··· 522 521 } 523 522 } 524 523 525 -func semLexerFallback(content string, base []rawSpan, emit semEmitFn) { 524 +func semLexerFallback(content string, base []rawSpan, emit semEmitFunc) { 526 525 l := lexer.New("", []byte(content)) 527 526 528 527 var commentStart, commentEnd int // 0 = not inside a comment line
M
journal/ast/dump.go
··· 361 361 fmt.Fprintf(b, "Comment %s\n", c.Span) 362 362 indent(b, depth+1) 363 363 fmt.Fprintf(b, "Marker: %q\n", string(c.Marker)) 364 + if len(c.Tags) > 0 { 365 + indent(b, depth+1) 366 + fmt.Fprintf(b, "Tags\n") 367 + for _, t := range c.Tags { 368 + indent(b, depth+2) 369 + fmt.Fprintf(b, "%q=%q %s\n", t.Key, t.Value, t.Span) 370 + } 371 + } 364 372 indent(b, depth+1) 365 373 fmt.Fprintf(b, "Text: %q\n", c.Text) 366 374 }
M
journal/parser/parser.go
··· 4 4 "fmt" 5 5 "strconv" 6 6 "strings" 7 + "unicode" 8 + "unicode/utf8" 7 9 8 10 "olexsmir.xyz/clerk/internal/decimal" 9 11 "olexsmir.xyz/clerk/journal/ast" ··· 1072 1074 p.advance() 1073 1075 p.skipWhitespace() 1074 1076 1077 + var tags []ast.Tag 1075 1078 text := "" 1076 1079 if p.got(token.TEXT) { 1077 1080 text = p.cur.Literal 1081 + tags = parseCommentTags(text, p.cur.Span.Start) 1078 1082 p.advance() 1079 1083 } 1080 1084 1081 1085 return &ast.Comment{ 1082 1086 Marker: marker, 1087 + Tags: tags, 1083 1088 Text: text, 1084 1089 Span: p.span(s), 1085 1090 } ··· 1333 1338 } 1334 1339 return 0 1335 1340 } 1341 + 1342 +// parseCommentTags extacts tags from comment text. 1343 +// A tag is a word immediately followed by a ':', with an optional value that ends at a comma or the end of a line. 1344 +// https://hledger.org/1.52/hledger.html?highlight=tags#tags 1345 +func parseCommentTags(text string, base token.Pos) []ast.Tag { 1346 + var tags []ast.Tag 1347 + for i := 0; i < len(text); { 1348 + colon := strings.IndexByte(text[i:], ':') 1349 + if colon < 0 { 1350 + break 1351 + } 1352 + colon += i 1353 + 1354 + keyStart := colon 1355 + for keyStart > i { 1356 + r, size := utf8.DecodeLastRuneInString(text[:keyStart]) 1357 + if unicode.IsSpace(r) { 1358 + break 1359 + } 1360 + keyStart -= size 1361 + } 1362 + if keyStart == colon { // nothing before the colon = not a tag 1363 + i = colon + 1 1364 + continue 1365 + } 1366 + key := text[keyStart:colon] 1367 + 1368 + valueEnd := colon + 1 1369 + for valueEnd < len(text) && text[valueEnd] != ',' { 1370 + valueEnd++ 1371 + } 1372 + value := strings.TrimSpace(text[colon+1 : valueEnd]) 1373 + 1374 + tags = append(tags, ast.Tag{ 1375 + Key: key, 1376 + Value: value, 1377 + Span: token.Span{ 1378 + Start: tagPos(base, text, keyStart), 1379 + End: tagPos(base, text, valueEnd), 1380 + }, 1381 + }) 1382 + i = valueEnd 1383 + if i < len(text) && text[i] == ',' { 1384 + i++ 1385 + } 1386 + } 1387 + 1388 + return tags 1389 +} 1390 + 1391 +func tagPos(base token.Pos, text string, off int) token.Pos { 1392 + return token.Pos{ 1393 + File: base.File, 1394 + Offset: base.Offset + off, 1395 + Line: base.Line, 1396 + Col: base.Col + utf8.RuneCountInString(text[:off]), 1397 + } 1398 +}
M
journal/parser/parser_test.go
··· 15 15 "comment hash", 16 16 "comment percent", 17 17 "comment star", 18 + "comment with tags", 19 + "transaction with tag comments", 18 20 "alias directive", 19 21 "tag directive", 20 22 "year directive", ··· 130 132 f.Add([]byte("~ monthly\n expenses:food $100\n assets:checking\n")) 131 133 f.Add([]byte("= /^Income/\n expenses:food $10\n")) 132 134 f.Add([]byte("; a comment\n")) 135 + f.Add([]byte("; tag1:val1, tag2:val2\n")) 133 136 f.Add([]byte("comment\nbody\nend\n")) 134 137 f.Add([]byte("\n\n\n")) 135 138 f.Add([]byte("перевірка\n"))
A
journal/parser/testdata/transaction_with_tag_comments.txtar
··· 1 +-- input -- 2 +2024-01-15 Business dinner ; client:acme, project:alpha 3 + expenses:meals $50 ; billable: 4 + assets:cash ; date:2024-01-16 5 + 6 +-- expect -- 7 +Journal 8 + Transaction j:1:1-5:0 9 + Date: 2024-01-15 10 + Payee: "Business dinner" j:1:12-1:27 11 + Comment j:1:29-2:0 12 + Marker: ";" 13 + Tags 14 + "client"="acme" j:1:31-1:42 15 + "project"="alpha" j:1:44-1:57 16 + Text: "client:acme, project:alpha" 17 + Posting j:2:1-3:1 18 + Account j:2:5-2:19 19 + SubAccount: "expenses" j:2:5-2:13 20 + SubAccount: "meals" j:2:14-2:19 21 + Amount j:2:23-2:26 22 + Quantity: 50 23 + Commodity: "$" j:2:23-2:24 24 + CommodityPos: Before 25 + HasSpace: false 26 + Precision: 0 27 + Decimal: "." 28 + Comment j:2:28-3:0 29 + Marker: ";" 30 + Tags 31 + "billable"="" j:2:30-2:39 32 + Text: "billable:" 33 + Posting j:3:1-5:0 34 + Account j:3:5-3:16 35 + SubAccount: "assets" j:3:5-3:11 36 + SubAccount: "cash" j:3:12-3:16 37 + Amount: <elided> 38 + Comment j:3:28-4:0 39 + Marker: ";" 40 + Tags 41 + "date"="2024-01-16" j:3:30-3:45 42 + Text: "date:2024-01-16" 43 + BlankLine j:5:0-5:1