84 files changed,
1055 insertions(+),
367 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-12 16:49:20 +0300
Authored at:
2026-08-12 16:17:28 +0300
Change ID:
qnkoyolslopnwkwkowmzrmlnloqluxyl
Parent:
2a7dd08
jump to
M
internal/analyzer/analyzer.go
··· 17 17 Commodities map[string]*CommodityInfo 18 18 Payees map[string]*PayeeInfo 19 19 20 + // AccountAliases maps alias names to the account they resolve to, from 21 + // account "alias" subdirectives and top-level "alias A = B" directives. 22 + AccountAliases map[string]string 23 + 20 24 AccountNames []string // sorted for binary search 21 25 PayeeNames []string // sorted, all payee names from directives + usage 22 26 AccountsByPrefix map[string][]string // "expenses:" -> ["expenses:food", "expenses:taxi"]
M
internal/analyzer/analyzer_test.go
··· 31 31 "tags", 32 32 "decimal-marks", 33 33 "duplicate-transactions", 34 + "aliases", 34 35 } 35 36 36 37 for _, tname := range tests { ··· 109 110 if info.LastUsed.Year != 0 { 110 111 fmt.Fprintf(w, " last-used: %d-%02d-%02d\n", info.LastUsed.Year, info.LastUsed.Month, info.LastUsed.Day) 111 112 } 113 + } 114 + 115 + // account aliases 116 + anames = anames[:0] 117 + for name := range a.AccountAliases { 118 + anames = append(anames, name) 119 + } 120 + sort.Strings(anames) 121 + fmt.Fprintf(w, "\naliases (%d):\n", len(anames)) 122 + for _, name := range anames { 123 + fmt.Fprintf(w, " %s -> %s\n", name, a.AccountAliases[name]) 112 124 } 113 125 114 126 // payees
M
internal/analyzer/build.go
··· 19 19 a := &Analysis{ 20 20 Files: rj.Occurrences, 21 21 Accounts: make(map[string]*AccountInfo), 22 + AccountAliases: make(map[string]string), 22 23 Commodities: make(map[string]*CommodityInfo), 23 24 Payees: make(map[string]*PayeeInfo), 24 25 Tags: make(map[string]*TagInfo), ··· 62 63 a.addAccountDirective(e) 63 64 case *ast.CommodityDirective: 64 65 a.addCommodityDirective(e) 66 + case *ast.AliasDirective: 67 + a.addAliasDirective(e) 65 68 case *ast.PayeeDirective: 66 69 a.addPayeeDirective(e) 67 70 case *ast.TagDirective: ··· 119 122 a.Accounts[aname] = info 120 123 } 121 124 info.Directives = append(info.Directives, ad) 125 + for _, sd := range ad.Subdirectives { 126 + if sd.Name == "alias" { 127 + a.AccountAliases[sd.Value] = aname 128 + } 129 + } 130 +} 131 + 132 +// addAliasDirective records a top-level "alias A = B" directive; the alias 133 +// source name resolves to the target account. 134 +func (a *Analysis) addAliasDirective(ad *ast.AliasDirective) { 135 + a.AccountAliases[ad.From.String()] = ad.To.String() 122 136 } 123 137 124 138 func (a *Analysis) addPayeeDirective(pd *ast.PayeeDirective) {
M
internal/analyzer/testdata/account-indexing.txtar
··· 51 51 file 0: expenses:transport:fuel 52 52 last-used: 2024-01-02 53 53 54 +aliases (0): 55 + 54 56 payees (2): 55 57 groceries 56 58 directives: 0 ··· 106 108 107 109 tag values (0): 108 110 109 -commodity decimal marks (1): 110 - $: . 111 +commodity decimal marks (0):
M
internal/analyzer/testdata/account-usage.txtar
··· 43 43 file 0: expenses:food 44 44 last-used: 2024-01-15 45 45 46 +aliases (0): 47 + 46 48 payees (3): 47 49 dinner 48 50 directives: 0 ··· 99 101 100 102 tag values (0): 101 103 102 -commodity decimal marks (1): 103 - $: . 104 +commodity decimal marks (0):
A
internal/analyzer/testdata/aliases.txtar
··· 1 +-- in.journal -- 2 +account expenses:food 3 + alias food 4 + alias groceries 5 + 6 +alias checking = assets:bank:checking 7 +alias food = expenses:food 8 + 9 +2024-01-01 groceries 10 + food $10 11 + checking $5 12 + 13 +-- expect -- 14 +files (1): 15 + 0: in.journal 16 + 17 +commodities (1): 18 + $ 19 + directives: 0 20 + used: 2 21 + usages: 2 22 + file 0: $ ("10") 23 + file 0: $ ("5") 24 + last-used: 2024-01-01 25 + 26 +accounts (3): 27 + checking 28 + directives: 0 29 + used: 1 30 + usages: 1 31 + file 0: checking 32 + last-used: 2024-01-01 33 + expenses:food 34 + directives: 1 35 + used: 0 36 + account expenses:food 37 + usages: 0 38 + food 39 + directives: 0 40 + used: 1 41 + usages: 1 42 + file 0: food 43 + last-used: 2024-01-01 44 + 45 +aliases (3): 46 + checking -> assets:bank:checking 47 + food -> expenses:food 48 + groceries -> expenses:food 49 + 50 +payees (1): 51 + groceries 52 + directives: 0 53 + used: 1 54 + usages: 1 55 + file 0: groceries 56 + 57 +prefixes (1): 58 + expenses: 59 + expenses:food 60 + 61 +transactions: 1 62 +periodic transactions: 0 63 +automated transactions: 0 64 + 65 +directives (3): 66 + account expenses:food 67 + alias checking = assets:bank:checking 68 + alias food = expenses:food 69 + 70 +transaction keys (1): 71 + 2024-01-01|groceries|food,checking, 72 + 73 +payee templates (1): 74 + groceries 75 + food $10 76 + checking $5 77 + 78 +dates (1): 79 + 2024-01-01 80 + 81 +tags (0): 82 + 83 +tag names (0): 84 + 85 +tag values (0): 86 + 87 +commodity decimal marks (0):
M
internal/analyzer/testdata/commodity-usage.txtar
··· 53 53 file 0: expenses:food 54 54 last-used: 2024-01-03 55 55 56 +aliases (0): 57 + 56 58 payees (3): 57 59 exchange 58 60 directives: 0 ··· 109 111 110 112 tag values (0): 111 113 112 -commodity decimal marks (3): 113 - $: . 114 - AAPL: . 115 - EUR: . 114 +commodity decimal marks (0):
M
internal/analyzer/testdata/cost-balance.txtar
··· 39 39 file 0: assets:stocks 40 40 last-used: 2024-01-01 41 41 42 +aliases (0): 43 + 42 44 payees (1): 43 45 buy stock 44 46 directives: 0 ··· 75 77 76 78 tag values (0): 77 79 78 -commodity decimal marks (2): 80 +commodity decimal marks (1): 79 81 $: . 80 - AAPL: .
M
internal/analyzer/testdata/dates.txtar
··· 50 50 file 0: expenses:food 51 51 last-used: 2024-01-03 52 52 53 +aliases (0): 54 + 53 55 payees (4): 54 56 also-first 55 57 directives: 0 ··· 115 117 116 118 tag values (0): 117 119 118 -commodity decimal marks (1): 119 - $: . 120 +commodity decimal marks (0):
M
internal/analyzer/testdata/duplicate-transactions.txtar
··· 36 36 file 0: expenses:food 37 37 last-used: 2024-01-01 38 38 39 +aliases (0): 40 + 39 41 payees (1): 40 42 groceries 41 43 directives: 0 ··· 73 75 74 76 tag values (0): 75 77 76 -commodity decimal marks (1): 77 - $: . 78 +commodity decimal marks (0):
M
internal/analyzer/testdata/journal.txtar
··· 93 93 usages: 1 94 94 file 0: expenses:transport 95 95 96 +aliases (0): 97 + 96 98 payees (3): 97 99 aws 98 100 directives: 0 ··· 158 160 159 161 tag values (0): 160 162 161 -commodity decimal marks (2): 162 - $: . 163 +commodity decimal marks (1): 163 164 USD: .
M
internal/analyzer/testdata/payees.txtar
··· 48 48 file 0: expenses:food 49 49 last-used: 2024-01-03 50 50 51 +aliases (0): 52 + 51 53 payees (2): 52 54 groceries 53 55 directives: 0 ··· 98 100 99 101 tag values (0): 100 102 101 -commodity decimal marks (1): 102 - $: . 103 +commodity decimal marks (0):
M
internal/analyzer/testdata/posting-types.txtar
··· 38 38 file 0: virtual:unbalanced 39 39 last-used: 2024-01-01 40 40 41 +aliases (0): 42 + 41 43 payees (1): 42 44 test 43 45 directives: 0 ··· 76 78 77 79 tag values (0): 78 80 79 -commodity decimal marks (1): 80 - $: . 81 +commodity decimal marks (0):
M
internal/analyzer/testdata/typed-entries.txtar
··· 58 58 usages: 1 59 59 file 0: expenses:transport 60 60 61 +aliases (0): 62 + 61 63 payees (1): 62 64 groceries 63 65 directives: 0 ··· 98 100 99 101 tag values (0): 100 102 101 -commodity decimal marks (1): 102 - $: . 103 +commodity decimal marks (0):
M
internal/linter/rule_invalid_type_tag.go
··· 8 8 "olexsmir.xyz/clerk/journal/ast" 9 9 ) 10 10 11 -// InvalidTypeTag flags type tag on account directive whose value is not valid account type code. 11 +// InvalidTypeTag flags account type declarations (the type: tag and the type 12 +// subdirective) whose value is not a valid account type code. 12 13 type InvalidTypeTag struct{} 13 14 14 15 func (InvalidTypeTag) ID() RuleID { return "invalid-type-tag" } ··· 17 18 var finds []Find 18 19 for _, entry := range an.Directives { 19 20 ad, ok := entry.(*ast.AccountDirective) 20 - if !ok || ad.Comment == nil { 21 + if !ok { 21 22 continue 22 23 } 23 - for _, tag := range ad.Comment.Tags { 24 - if tag.Key != "type" { 24 + if ad.Comment != nil { 25 + for _, tag := range ad.Comment.Tags { 26 + if tag.Key != "type" { 27 + continue 28 + } 29 + if err := i.parseAccountTypeCode(tag.Value); err != nil { 30 + finds = append(finds, Find{ 31 + Code: i.ID(), 32 + Severity: i.Severity(), 33 + Span: tag.Span, 34 + Message: fmt.Sprintf("invalid type: tag value %q", tag.Value), 35 + }) 36 + } 37 + } 38 + } 39 + for _, sd := range ad.Subdirectives { 40 + if sd.Name != "type" || sd.Value == "" { 41 + // empty values are flagged by the parser 25 42 continue 26 43 } 27 - if err := i.parseAccountTypeCode(tag.Value); err != nil { 44 + if err := i.parseAccountTypeCode(sd.Value); err != nil { 28 45 finds = append(finds, Find{ 29 46 Code: i.ID(), 30 47 Severity: i.Severity(), 31 - Span: tag.Span, 32 - Message: fmt.Sprintf("invalid type: tag value %q", tag.Value), 48 + Span: sd.ValueSpan, 49 + Message: fmt.Sprintf("invalid type subdirective value %q", sd.Value), 33 50 }) 34 51 } 35 52 }
M
internal/linter/rule_missing_commodity.go
··· 29 29 m.check(&finds, e.From) 30 30 m.check(&finds, e.To) 31 31 case *ast.CommodityDirective: 32 - if e.Format.Commodity != "" { 33 - m.check(&finds, e.Format) 32 + if e.FormatSub != nil && e.FormatSub.Amount.Commodity != "" { 33 + m.check(&finds, e.FormatSub.Amount) 34 34 } 35 35 case *ast.DefaultCommodityDirective: 36 36 m.check(&finds, e.Amount)
M
internal/linter/testdata/invalid-type-tag.txtar
··· 14 14 account typo:code ; type:Expenc 15 15 16 16 account caps:key ; TYPE:asset 17 +account sub:type 18 + type Z 19 +account sub:good 20 + type expense 21 +account sub:bad 22 + type Expenc 23 +account sub:empty 24 + type 17 25 2026-01-15 Shopping ; type:whatever 18 26 Expenses:Food $10 19 27 Assets:Cash ··· 24 32 in.journal:11:26: invalid-type-tag: invalid type: tag value "Z" 25 33 in.journal:12:26: invalid-type-tag: invalid type: tag value "" 26 34 in.journal:13:26: invalid-type-tag: invalid type: tag value "Expenc" 35 +in.journal:17:8: invalid-type-tag: invalid type subdirective value "Z" 36 +in.journal:21:8: invalid-type-tag: invalid type subdirective value "Expenc"
M
internal/linter/testdata/undeclared-account.txtar
··· 1 1 -- in.journal -- 2 2 account Expenses:Food 3 + alias food 4 + 5 +account Assets:Bank 6 + alias bank 7 + 8 +alias cash = Assets:Cash 3 9 4 10 2026-01-15 * "Lunch" 5 - Expenses:Food $10 11 + food $10 6 12 Assets:Cash 7 13 8 14 2026-01-16 * "Dinner" 9 15 Expenses:Food $15 10 - Assets:Cash 16 + cash 11 17 12 18 -- expect -- 13 -in.journal:5:3: undeclared-account: undeclared account: Assets:Cash 14 -in.journal:9:3: undeclared-account: undeclared account: Assets:Cash 19 +in.journal:11:3: undeclared-account: undeclared account: Assets:Cash
M
internal/lsp/testdata/definition-journal.txtar
··· 1 1 -- in.journal -- 2 2 account ex^penses:food 3 3 account assets:checking 4 +account expenses:food 5 + alias foo^d 6 +alias fo^od = expenses:food 7 +alias food = expenses:fo^od 4 8 account untracked 5 9 no^te ignore 6 10 ··· 30 34 31 35 -- expect -- 32 36 0:10 in.journal 0:8-0:21 33 -3:6 <none> 34 -5:10 in.journal 5:10-5:11 35 -8:13 in.journal 8:6-8:19 36 -10:0 <none> 37 -10:18 in.journal 8:6-8:19 38 -10:29 <none> 39 -11:4 in.journal 0:8-0:21 40 -11:17 in.journal 0:8-0:21 41 -11:21 <none> 42 -12:19 in.journal 5:10-5:11 43 -13:23 in.journal 5:10-5:11 44 -14:25 in.journal 14:24-14:27 45 -16:18 in.journal 16:11-16:24 46 -18:25 in.journal 14:24-14:27 47 -19:27 in.journal 5:10-5:11 48 -25:15 in.journal 21:11-21:19 49 -27:13 in.journal 14:4-14:15 37 +3:11 in.journal 0:8-0:21 38 +4:8 in.journal 0:8-0:21 39 +5:24 in.journal 0:8-0:21 40 +7:6 <none> 41 +9:10 in.journal 9:10-9:11 42 +12:13 in.journal 12:6-12:19 43 +14:0 <none> 44 +14:18 in.journal 12:6-12:19 45 +14:29 <none> 46 +15:4 in.journal 0:8-0:21 47 +15:17 in.journal 0:8-0:21 48 +15:21 <none> 49 +16:19 in.journal 9:10-9:11 50 +17:23 in.journal 9:10-9:11 51 +18:25 in.journal 18:24-18:27 52 +20:18 in.journal 20:11-20:24 53 +22:25 in.journal 18:24-18:27 54 +23:27 in.journal 9:10-9:11 55 +29:15 in.journal 25:11-25:19 56 +31:13 in.journal 18:4-18:15
M
internal/lsp/testdata/rename-account.txtar
··· 4 4 account ex^penses 5 5 account expenses2 6 6 account expenses:food 7 + alias foo^d 8 +alias fo^od = expenses:food 9 +alias food = expenses:fo^od 7 10 8 11 2024-01-15 Grocery Store 9 12 expenses:food $50 ··· 23 26 -- rename -- 24 27 costs 25 28 food 29 +food 30 +food 31 +costs:food 26 32 27 33 -- expect -- 28 34 2:10 29 35 prep in.journal 2:8-2:16 "expenses" 30 36 ren in.journal 2:8-2:16 "costs" 31 37 ren in.journal 4:8-4:21 "costs:food" 32 - ren in.journal 7:4-7:17 "costs:food" 33 - ren in.journal 9:5-9:18 "costs:food" 34 - ren in.journal 12:4-12:17 "costs:food" 38 + ren in.journal 6:13-6:26 "costs:food" 39 + ren in.journal 7:13-7:26 "costs:food" 40 + ren in.journal 10:4-10:17 "costs:food" 41 + ren in.journal 12:5-12:18 "costs:food" 42 + ren in.journal 15:4-15:17 "costs:food" 35 43 ren sub.journal 0:8-0:21 "costs:food" 36 44 ren sub.journal 3:4-3:17 "costs:food" 37 -12:9 38 - prep in.journal 12:4-12:17 "expenses:food" 45 +5:11 46 + prep in.journal 5:8-5:12 "food" 47 + ren in.journal 5:8-5:12 "food" 48 + ren in.journal 6:6-6:10 "food" 49 + ren in.journal 7:6-7:10 "food" 50 +6:8 51 + prep in.journal 6:6-6:10 "food" 52 + ren in.journal 5:8-5:12 "food" 53 + ren in.journal 6:6-6:10 "food" 54 + ren in.journal 7:6-7:10 "food" 55 +7:24 56 + prep in.journal 7:13-7:26 "expenses:food" 39 57 ren in.journal 4:8-4:21 "food" 40 - ren in.journal 7:4-7:17 "food" 41 - ren in.journal 9:5-9:18 "food" 42 - ren in.journal 12:4-12:17 "food" 58 + ren in.journal 6:13-6:26 "food" 59 + ren in.journal 7:13-7:26 "food" 60 + ren in.journal 10:4-10:17 "food" 61 + ren in.journal 12:5-12:18 "food" 62 + ren in.journal 15:4-15:17 "food" 43 63 ren sub.journal 0:8-0:21 "food" 44 64 ren sub.journal 3:4-3:17 "food" 65 +15:9 66 + prep in.journal 15:4-15:17 "expenses:food" 67 + ren in.journal 4:8-4:21 "costs:food" 68 + ren in.journal 6:13-6:26 "costs:food" 69 + ren in.journal 7:13-7:26 "costs:food" 70 + ren in.journal 10:4-10:17 "costs:food" 71 + ren in.journal 12:5-12:18 "costs:food" 72 + ren in.journal 15:4-15:17 "costs:food" 73 + ren sub.journal 0:8-0:21 "costs:food" 74 + ren sub.journal 3:4-3:17 "costs:food"
M
internal/lsp/testdata/semantic-directives.txtar
··· 1 1 -- in.journal -- 2 2 account expenses:food 3 3 account expenses:food ; note 4 +account expenses:food 5 + alias food 6 + alias groceries ; alias note 7 +account expenses:food ; account note 8 + ; block comment line 9 + alias food 10 +account expenses:food 11 + type X 12 + note grocery shopping ; note comment 13 + # hash comment line 4 14 5 15 alias old = new 6 16 alias old = new ; alias note ··· 75 85 1:0+7 keyword 76 86 1:8+13 namespace 77 87 1:23+6 comment 78 -3:0+5 keyword 79 -3:6+3 namespace 80 -3:10+1 operator 81 -3:12+3 namespace 82 -4:0+5 keyword 83 -4:6+3 namespace 84 -4:10+1 operator 85 -4:12+3 namespace 86 -4:17+12 comment 87 -6:0+5 keyword 88 -6:6+10 property 89 -7:0+5 keyword 90 -7:6+10 property 91 -7:18+12 comment 92 -9:0+3 keyword 93 -9:4+3 property 94 -10:0+3 keyword 95 -10:4+3 property 96 -10:9+10 comment 97 -12:0+4 keyword 98 -12:5+4 property 99 -13:0+4 keyword 100 -13:5+4 property 101 -13:11+11 comment 102 -15:0+1 keyword 103 -15:2+4 property 104 -16:0+1 keyword 105 -16:2+4 property 106 -16:8+8 comment 107 -18:0+5 keyword 108 -18:6+16 property 88 +2:0+7 keyword 89 +2:8+13 namespace 90 +3:2+5 keyword 91 +3:8+4 namespace 92 +4:2+5 keyword 93 +4:8+9 namespace 94 +4:19+12 comment 95 +5:0+7 keyword 96 +5:8+13 namespace 97 +5:23+14 comment 98 +6:2+20 comment 99 +7:2+5 keyword 100 +7:8+4 namespace 101 +8:0+7 keyword 102 +8:8+13 namespace 103 +9:2+4 keyword 104 +9:7+1 property 105 +10:2+4 keyword 106 +10:7+16 property 107 +10:25+14 comment 108 +11:2+19 comment 109 +13:0+5 keyword 110 +13:6+3 namespace 111 +13:10+1 operator 112 +13:12+3 namespace 113 +14:0+5 keyword 114 +14:6+3 namespace 115 +14:10+1 operator 116 +14:12+3 namespace 117 +14:17+12 comment 118 +16:0+5 keyword 119 +16:6+10 property 120 +17:0+5 keyword 121 +17:6+10 property 122 +17:18+12 comment 109 123 19:0+3 keyword 110 -19:4+5 property 111 -20:0+5 keyword 112 -20:6+16 property 113 -20:24+12 comment 114 -21:0+3 keyword 115 -21:4+5 property 116 -21:11+10 comment 117 -23:0+5 keyword 118 -23:6+11 property 119 -24:0+3 keyword 120 -24:4+5 property 121 -25:0+5 keyword 122 -25:6+11 property 123 -25:19+16 comment 124 -26:0+3 keyword 125 -26:4+5 property 126 -26:11+10 comment 124 +19:4+3 property 125 +20:0+3 keyword 126 +20:4+3 property 127 +20:9+10 comment 128 +22:0+4 keyword 129 +22:5+4 property 130 +23:0+4 keyword 131 +23:5+4 property 132 +23:11+11 comment 133 +25:0+1 keyword 134 +25:2+4 property 135 +26:0+1 keyword 136 +26:2+4 property 137 +26:8+8 comment 127 138 28:0+5 keyword 128 -28:6+15 property 139 +28:6+16 property 129 140 29:0+3 keyword 130 141 29:4+5 property 131 142 30:0+5 keyword 132 -30:6+15 property 133 -30:23+18 comment 143 +30:6+16 property 144 +30:24+12 comment 134 145 31:0+3 keyword 135 146 31:4+5 property 136 147 31:11+10 comment 137 -33:0+1 keyword 138 -33:2+7 property 139 -34:0+1 keyword 140 -34:2+7 property 141 -34:11+8 comment 142 -35:0+1 keyword 143 -35:2+1 property 144 -36:0+1 keyword 145 -36:2+1 property 146 -36:5+15 comment 147 -38:0+1 keyword 148 -38:2+1 type 149 -38:3+8 number 150 -39:0+1 keyword 151 -39:2+1 type 152 -39:3+8 number 153 -39:13+8 comment 154 -41:0+1 keyword 155 -41:2+10 class 156 -41:13+3 type 157 -41:17+4 number 158 -42:0+1 keyword 159 -42:2+10 class 160 -42:13+3 type 161 -42:17+4 number 162 -42:23+8 comment 148 +33:0+5 keyword 149 +33:6+11 property 150 +34:0+3 keyword 151 +34:4+5 property 152 +35:0+5 keyword 153 +35:6+11 property 154 +35:19+16 comment 155 +36:0+3 keyword 156 +36:4+5 property 157 +36:11+10 comment 158 +38:0+5 keyword 159 +38:6+15 property 160 +39:0+3 keyword 161 +39:4+5 property 162 +40:0+5 keyword 163 +40:6+15 property 164 +40:23+18 comment 165 +41:0+3 keyword 166 +41:4+5 property 167 +41:11+10 comment 163 168 43:0+1 keyword 164 -43:2+10 class 165 -43:13+8 class 166 -43:22+3 type 167 -43:26+5 number 169 +43:2+7 property 168 170 44:0+1 keyword 169 -44:2+10 class 170 -44:13+8 class 171 -44:22+3 type 172 -44:26+5 number 173 -44:33+13 comment 171 +44:2+7 property 172 +44:11+8 comment 173 +45:0+1 keyword 174 +45:2+1 property 174 175 46:0+1 keyword 175 -46:2+3 number 176 -46:6+3 type 177 -46:10+1 operator 178 -46:12+2 number 179 -46:15+3 type 180 -47:0+1 keyword 181 -47:2+3 number 182 -47:6+3 type 183 -47:10+1 operator 184 -47:12+2 number 185 -47:15+3 type 186 -47:20+8 comment 187 -49:0+12 keyword 188 -49:13+1 property 189 -50:0+12 keyword 190 -50:13+1 property 191 -50:16+14 comment 192 -52:0+7 keyword 193 -52:8+11 string 194 -53:0+7 keyword 195 -53:8+11 string 196 -53:21+14 comment 197 -55:0+9 keyword 198 -55:10+3 type 199 -56:0+9 keyword 200 -56:10+3 type 201 -56:15+16 comment 202 -58:0+9 keyword 203 -59:9+3 type 204 -59:13+14 number 205 -60:0+9 keyword 206 -60:15+20 comment 207 -61:9+3 type 208 -61:13+14 number 209 -63:0+9 keyword 210 -63:10+8 number 211 -63:19+3 type 212 -64:0+9 keyword 213 -64:10+8 number 214 -64:19+3 type 215 -64:24+23 comment 176 +46:2+1 property 177 +46:5+15 comment 178 +48:0+1 keyword 179 +48:2+1 type 180 +48:3+8 number 181 +49:0+1 keyword 182 +49:2+1 type 183 +49:3+8 number 184 +49:13+8 comment 185 +51:0+1 keyword 186 +51:2+10 class 187 +51:13+3 type 188 +51:17+4 number 189 +52:0+1 keyword 190 +52:2+10 class 191 +52:13+3 type 192 +52:17+4 number 193 +52:23+8 comment 194 +53:0+1 keyword 195 +53:2+10 class 196 +53:13+8 class 197 +53:22+3 type 198 +53:26+5 number 199 +54:0+1 keyword 200 +54:2+10 class 201 +54:13+8 class 202 +54:22+3 type 203 +54:26+5 number 204 +54:33+13 comment 205 +56:0+1 keyword 206 +56:2+3 number 207 +56:6+3 type 208 +56:10+1 operator 209 +56:12+2 number 210 +56:15+3 type 211 +57:0+1 keyword 212 +57:2+3 number 213 +57:6+3 type 214 +57:10+1 operator 215 +57:12+2 number 216 +57:15+3 type 217 +57:20+8 comment 218 +59:0+12 keyword 219 +59:13+1 property 220 +60:0+12 keyword 221 +60:13+1 property 222 +60:16+14 comment 223 +62:0+7 keyword 224 +62:8+11 string 225 +63:0+7 keyword 226 +63:8+11 string 227 +63:21+14 comment 216 228 65:0+9 keyword 217 -66:9+8 number 218 -66:18+3 type 219 -67:0+9 keyword 220 -67:15+20 comment 221 -68:9+8 number 222 -68:18+3 type 229 +65:10+3 type 230 +66:0+9 keyword 231 +66:10+3 type 232 +66:15+16 comment 233 +68:0+9 keyword 234 +69:2+6 keyword 235 +69:9+3 type 236 +69:13+14 number 237 +70:0+9 keyword 238 +70:15+20 comment 239 +71:2+6 keyword 240 +71:9+3 type 241 +71:13+14 number 242 +73:0+9 keyword 243 +73:10+8 number 244 +73:19+3 type 245 +74:0+9 keyword 246 +74:10+8 number 247 +74:19+3 type 248 +74:24+23 comment 249 +75:0+9 keyword 250 +76:2+6 keyword 251 +76:9+8 number 252 +76:18+3 type 253 +77:0+9 keyword 254 +77:15+20 comment 255 +78:2+6 keyword 256 +78:9+8 number 257 +78:18+3 type
M
internal/lsp/testdata/semantic-with-errors.txtar
··· 5 5 6 6 some broken input 7 7 8 +commodity $ 9 + format 1.000,00 10 + 11 +account assets:bank 12 + payee ^store$ 13 + 8 14 -- expect -- 9 15 0:0+10 class 10 16 0:11+6 property ··· 14 20 2:2+11 namespace 15 21 2:17+1 type 16 22 2:18+7 number negative 23 +6:0+9 keyword 24 +6:10+1 type 25 +7:2+6 keyword 26 +7:9+8 number 27 +9:0+7 keyword 28 +9:8+11 namespace
M
internal/lsp/textdocument_completion.go
··· 430 430 431 431 func lexLine(content string, cursor int) []token.Token { 432 432 lineStart, lineEnd := lineBounds(content, cursor) 433 - l := lexer.New("", content[lineStart:lineEnd]) 433 + l := lexer.New("", []byte(content[lineStart:lineEnd])) 434 434 var out []token.Token 435 435 for { 436 436 t := l.Next()
M
internal/lsp/textdocument_format.go
··· 18 18 } 19 19 20 20 text := state.text 21 - lex := lexer.New(params.TextDocument.URI.Path(), text) 21 + lex := lexer.New(params.TextDocument.URI.Path(), []byte(text)) 22 22 jnrl := parser.New(lex).ParseJournal() 23 23 if len(jnrl.Errors) > 0 { 24 24 return nil, fmt.Errorf("can't format file with errors: %v", jnrl.Errors[0].Message) // TODO: report all errors
M
internal/lsp/textdocument_rename.go
··· 131 131 if spanContains(content, e.Account.Span, cursor) { 132 132 return &symbolRef{symbolAccount, e.Account.String(), e.Account.Span} 133 133 } 134 + for _, sd := range e.Subdirectives { 135 + if sd.Name == "alias" && spanContains(content, sd.ValueSpan, cursor) { 136 + return &symbolRef{symbolAccount, sd.Value, sd.ValueSpan} 137 + } 138 + } 139 + case *ast.AliasDirective: 140 + if spanContains(content, e.From.Span, cursor) { 141 + return &symbolRef{symbolAccount, e.From.String(), e.From.Span} 142 + } 143 + if spanContains(content, e.To.Span, cursor) { 144 + return &symbolRef{symbolAccount, e.To.String(), e.To.Span} 145 + } 134 146 case *ast.CommodityDirective: 135 147 if spanContains(content, e.CommoditySpan, cursor) { 136 148 return &symbolRef{symbolCommodity, e.Commodity, e.CommoditySpan} ··· 280 292 case *ast.AccountDirective: 281 293 if text, ok := ref.renameTo(symbolAccount, e.Account.String(), newName); ok { 282 294 add(e.Account.Span, text) 295 + } 296 + for _, sd := range e.Subdirectives { 297 + if sd.Name == "alias" { 298 + if text, ok := ref.renameTo(symbolAccount, sd.Value, newName); ok { 299 + add(sd.ValueSpan, text) 300 + } 301 + } 302 + } 303 + case *ast.AliasDirective: 304 + if text, ok := ref.renameTo(symbolAccount, e.From.String(), newName); ok { 305 + add(e.From.Span, text) 306 + } 307 + if text, ok := ref.renameTo(symbolAccount, e.To.String(), newName); ok { 308 + add(e.To.Span, text) 283 309 } 284 310 case *ast.CommodityDirective: 285 311 if text, ok := ref.renameTo(symbolCommodity, e.Commodity, newName); ok {
M
internal/lsp/textdocument_semantic.go
··· 203 203 case *ast.AccountDirective: 204 204 emit(directiveKeyword(e.Span, "account"), semDirective, 0) 205 205 emit(e.Account.Span, semAccount, 0) 206 + for _, sd := range e.Subdirectives { 207 + if sd.Name == "" { 208 + emitComment(sd.Comment, emit) 209 + continue 210 + } 211 + emit(sd.NameSpan, semDirective, 0) 212 + switch sd.Name { 213 + case "alias": 214 + emit(sd.ValueSpan, semAccount, 0) 215 + case "type", "note": 216 + emit(sd.ValueSpan, semProperty, 0) 217 + } 218 + emitComment(sd.Comment, emit) 219 + } 206 220 emitComment(e.Comment, emit) 207 221 case *ast.CommodityDirective: 208 222 emit(directiveKeyword(e.Span, "commodity"), semDirective, 0) 209 - if e.Format.Span.End.Offset > 0 { 210 - semEmitAmount(content, &e.Format, emit) 223 + if e.FormatSub != nil { 224 + if e.FormatSub.KeywordSpan.End.Offset > 0 { 225 + emit(e.FormatSub.KeywordSpan, semDirective, 0) 226 + } 227 + semEmitAmount(content, &e.FormatSub.Amount, emit) 228 + emitComment(e.FormatSub.Comment, emit) 211 229 } else if e.CommoditySpan.Start.Offset > 0 && e.CommoditySpan.End.Offset > 0 { 212 230 emit(e.CommoditySpan, semCommodity, 0) 213 231 } 232 + emitBlockComments(e.BlockComments, emit) 214 233 emitComment(e.Comment, emit) 215 234 case *ast.IncludeDirective: 216 235 emitDirective(content, e.Span, len("include"), semString, e.Comment, emit) ··· 464 483 465 484 type semEmitFunc func(tok token.Span, tokKind, modifier uint32) 466 485 486 +func emitBlockComments(cs []*ast.Comment, emit semEmitFunc) { 487 + for _, c := range cs { 488 + emitComment(c, emit) 489 + } 490 +} 491 + 467 492 func emitComment(c *ast.Comment, emit semEmitFunc) { 468 493 if c == nil { 469 494 return ··· 522 547 } 523 548 524 549 func semLexerFallback(content string, base []rawSpan, emit semEmitFunc) { 525 - l := lexer.New("", content) 550 + l := lexer.New("", []byte(content)) 526 551 527 552 var commentStart, commentEnd int // 0 = not inside a comment line 528 553 lineStart := true // the next significant token starts a line ··· 615 640 return false 616 641 } 617 642 643 +// encodeSemTokens encodes tokens into LSP delta form. Input must be sorted by 644 +// line and column; [rawToSemanticTokens] produces such order. 618 645 func encodeSemTokens(tokens []semanticToken) []uint32 { 619 646 if len(tokens) == 0 { 620 647 return nil 621 648 } 622 - slices.SortFunc(tokens, func(a, b semanticToken) int { 623 - if a.line != b.line { 624 - return int(a.line) - int(b.line) 625 - } 626 - return int(a.col) - int(b.col) 627 - }) 628 649 data := make([]uint32, 0, len(tokens)*5) 629 650 var prevLine, prevCol uint32 630 651 for _, t := range tokens {
M
internal/lsp/textdocument_semantic_test.go
··· 34 34 0, 11, 5, semString, 0, 35 35 1, 4, 10, semAccount, 0, 36 36 }}, 37 - // the wire format requires non-negative deltas; unsorted input must 38 - // be sorted first (commodity at col 36 comes after amount at col 32) 39 - "unsorted input": {[]semanticToken{ 37 + // input must be sorted by line and column (rawToSemanticTokens output); 38 + // deltas would underflow otherwise 39 + "sorted": {[]semanticToken{ 40 40 {line: 0, col: 0, length: 10, tokenType: semDate}, 41 - {line: 0, col: 36, length: 3, tokenType: semCommodity}, 42 41 {line: 0, col: 32, length: 2, tokenType: semAmount}, 42 + {line: 0, col: 36, length: 3, tokenType: semCommodity}, 43 43 {line: 1, col: 4, length: 6, tokenType: semAccount}, 44 44 }, []uint32{ 45 45 0, 0, 10, semDate, 0,
M
journal/ast/directives.go
··· 3 3 import "olexsmir.xyz/clerk/journal/token" 4 4 5 5 type AccountDirective struct { 6 - Account Account 7 - Comment *Comment 8 - Span token.Span 6 + Account Account 7 + Subdirectives []AccountSubdirective // indented block lines, in order 8 + Comment *Comment 9 + Span token.Span 9 10 } 10 11 11 12 func (AccountDirective) entryNode() {} 13 + 14 +type AccountSubdirective struct { 15 + Name string // "alias" / "type" / "note" / "" for comment lines 16 + NameSpan token.Span // keyword span; for comment lines, the marker span 17 + Value string // text after the keyword; "" for comment lines 18 + ValueSpan token.Span // value span; zero for comment lines and empty values 19 + Comment *Comment // inline comment after the value; for comment lines, the line itself 20 +} 12 21 13 22 type CommodityDirective struct { 14 23 Commodity string 15 - CommoditySpan token.Span // span of the commodity token 16 - Format Amount // optional format hint: "1,000.00 UAH" 17 - Comment *Comment // optional inline comment 24 + CommoditySpan token.Span // span of the commodity token 25 + FormatSub *FormatSubDirective // display format; nil when not given 26 + BlockComments []*Comment // indented comment-only lines inside the block 27 + Comment *Comment // optional inline comment 18 28 Span token.Span 19 29 } 20 30 21 31 func (CommodityDirective) entryNode() {} 32 + 33 +// FormatSubDirective is a commodity display format, either inline on the 34 +// directive line ("commodity $1,000.00") or as a "format" subdirective. 35 +type FormatSubDirective struct { 36 + Amount Amount 37 + KeywordSpan token.Span // span of the "format" keyword; zero when inline 38 + Comment *Comment // inline comment; subdirective form only 39 +} 22 40 23 41 type PayeeDirective struct { 24 42 Name *Payee
M
journal/ast/dump.go
··· 272 272 } 273 273 indent(b, depth+1) 274 274 fmt.Fprintf(b, "Precision: %d\n", a.QuantityFmt.Precision) 275 - indent(b, depth+1) 276 - fmt.Fprintf(b, "Decimal: %q\n", string(a.QuantityFmt.Decimal)) 275 + if a.QuantityFmt.Decimal != 0 { 276 + indent(b, depth+1) 277 + fmt.Fprintf(b, "Decimal: %q\n", string(a.QuantityFmt.Decimal)) 278 + } 277 279 if a.QuantityFmt.Thousands != 0 { 278 280 indent(b, depth+1) 279 281 fmt.Fprintf(b, "Thousands: %q\n", string(a.QuantityFmt.Thousands)) ··· 318 320 indent(b, depth) 319 321 fmt.Fprintf(b, "AccountDirective %s\n", a.Span) 320 322 dumpAccount(b, a.Account, depth+1) 323 + for _, sd := range a.Subdirectives { 324 + if sd.Name == "" { 325 + dumpComment(b, sd.Comment, depth+1) 326 + continue 327 + } 328 + indent(b, depth+1) 329 + fmt.Fprintf(b, "Subdirective\n") 330 + indent(b, depth+2) 331 + fmt.Fprintf(b, "Name: %q %s\n", sd.Name, sd.NameSpan) 332 + indent(b, depth+2) 333 + fmt.Fprintf(b, "Value: %q %s\n", sd.Value, sd.ValueSpan) 334 + dumpOptComment(b, sd.Comment, depth+2) 335 + } 321 336 dumpOptComment(b, a.Comment, depth+1) 322 337 } 323 338 ··· 326 341 fmt.Fprintf(b, "CommodityDirective %s\n", c.Span) 327 342 indent(b, depth+1) 328 343 fmt.Fprintf(b, "Commodity: %q %s\n", c.Commodity, c.CommoditySpan) 329 - if c.Format.QuantityFmt.Decimal != 0 { 330 - dumpAmount(b, &c.Format, depth+1) 344 + if c.FormatSub != nil { 345 + indent(b, depth+1) 346 + if c.FormatSub.KeywordSpan.End.Offset > 0 { 347 + fmt.Fprintf(b, "Format %s\n", c.FormatSub.KeywordSpan) 348 + } else { 349 + fmt.Fprintf(b, "Format inline\n") 350 + } 351 + dumpAmount(b, &c.FormatSub.Amount, depth+2) 352 + dumpOptComment(b, c.FormatSub.Comment, depth+2) 353 + } 354 + for _, cm := range c.BlockComments { 355 + dumpComment(b, cm, depth+1) 331 356 } 332 357 dumpOptComment(b, c.Comment, depth+1) 333 358 }
M
journal/journal_test.go
··· 61 61 "unicode-cyrillic.journal": {desc: "synthetic: unicode Cyrillic in descriptions and account names"}, 62 62 "unicode-mixed-languages.journal": {desc: "synthetic: mixed latin/cyrillic/cjk in descriptions and account names"}, 63 63 "virtual-posting.dat": {desc: "ledger: virtual/balanced postings with [brackets]"}, 64 + "subdirectives.journal": {desc: "synthetic: account alias and commodity format subdirectives with comments"}, 64 65 } 65 66 66 67 func TestParserOnRealJournals(t *testing.T) {
M
journal/lexer/lexer.go
··· 41 41 42 42 type Lexer struct { 43 43 file string 44 - input string 44 + input []byte 45 45 mode mode 46 46 47 47 ch rune // current rune (0 = EOF/sentinel) ··· 57 57 58 58 includePath bool // true after [token.INCLUDE] is omitted 59 59 60 - // subdirective is set when the current line is an account/commodity 61 - // directive; the next indented line then lexes as directive content 60 + // subdirective is set while inside an account/commodity directive block; 61 + // every indented line then lexes as directive content until a 62 + // non-indented line start clears it in lexDefault 62 63 subdirective bool 63 64 } 64 65 65 -func New(file string, input string) *Lexer { 66 +func New(file string, input []byte) *Lexer { 66 67 l := &Lexer{ 67 68 file: file, 68 69 input: input, ··· 115 116 case l.ch == ' ' || l.ch == '\t': 116 117 tok := l.lexIndent() 117 118 if l.subdirective { 119 + // keep directive mode for every line of a directive block; the 120 + // flag is cleared at the next non-indented line start in lexDefault 118 121 l.mode = modeDirective 119 - l.subdirective = false 120 122 } else { 121 123 l.mode = modePosting 122 124 } ··· 690 692 l.ch = 0 691 693 l.chSize = 0 692 694 } else { 693 - r, size := utf8.DecodeRuneInString(l.input[l.rpos:]) 695 + r, size := utf8.DecodeRune(l.input[l.rpos:]) 694 696 l.ch = r 695 697 l.chSize = size 696 698 } ··· 705 707 } 706 708 707 709 func (l *Lexer) peek() rune { 708 - r, _ := utf8.DecodeRuneInString(l.input[l.rpos:]) 710 + r, _ := utf8.DecodeRune(l.input[l.rpos:]) 709 711 return r 710 712 } 711 713
M
journal/lexer/lexer_test.go
··· 35 35 for _, tt := range tests { 36 36 t.Run(tt, func(t *testing.T) { 37 37 a := golden.Read(t, tt) 38 - golden.Assert(t, a, New("j", string(a.Get("input"))).dump()) 38 + golden.Assert(t, a, New("j", a.Get("input")).dump()) 39 39 }) 40 40 } 41 41 } ··· 75 75 76 76 f.Fuzz(func(t *testing.T, data []byte) { 77 77 // Pass 1: lex and validate token stream 78 - l := New("j", string(data)) 78 + l := New("j", data) 79 79 var tokens []token.Token 80 80 maxTokens := max(len(data)*2, 16) 81 81 prevEnd := -1 ··· 125 125 } 126 126 127 127 // Pass 2: re-lex the same input — token stream must be identical 128 - l2 := New("j", string(data)) 128 + l2 := New("j", data) 129 129 for _, expected := range tokens { 130 130 tok := l2.Next() 131 131 if tok.Type != expected.Type || tok.Literal != expected.Literal {
M
journal/parser/parser.go
··· 342 342 comment := p.parseOptInlineComment() 343 343 p.expectNewline() 344 344 345 + var subs []ast.AccountSubdirective 345 346 for p.got(token.INDENT) { 346 347 p.advance() 347 - for !p.got(token.NEWLINE) && !p.got(token.EOF) { 348 + p.skipWhitespace() 349 + if p.got(token.NEWLINE) || p.got(token.EOF) { 350 + // whitespace-only line: block continues 351 + p.expectNewline() 352 + continue 353 + } 354 + switch { 355 + case p.got(token.SEMICOLON): 356 + // comment line: directive mode lexes only ';' as a comment marker 357 + s := p.cur.Span 358 + c := p.parseCommentRest(s) 359 + p.expectNewline() 360 + subs = append(subs, ast.AccountSubdirective{NameSpan: s, Comment: c}) 361 + case p.got(token.TEXT) && isCommentMarker(p.cur.Literal): 362 + // '#', '%' and '*' lex as TEXT in directive mode; treat them as comment lines 363 + c := p.parseTextComment() 364 + subs = append(subs, ast.AccountSubdirective{NameSpan: c.Span, Comment: c}) 365 + case p.got(token.TEXT): 366 + name := p.cur.Literal 367 + if !isAccountSubdirective(name) { 368 + p.errorf("unknown subdirective %q", name) 369 + p.skipToNewline() 370 + continue 371 + } 372 + kw := p.cur.Span 348 373 p.advance() 374 + value, vspan := p.parseSubdirectiveValue() 375 + if value == "" { 376 + p.errorf("expected value for subdirective %q", name) 377 + } 378 + c := p.parseOptInlineComment() 379 + p.expectNewline() 380 + subs = append(subs, ast.AccountSubdirective{ 381 + Name: name, 382 + NameSpan: kw, 383 + Value: value, 384 + ValueSpan: vspan, 385 + Comment: c, 386 + }) 387 + default: 388 + p.errorf("expected subdirective name, got %s", p.cur.Type) 389 + p.skipToNewline() 349 390 } 350 - p.expectNewline() 351 391 } 352 392 353 393 return &ast.AccountDirective{ 354 - Account: account, 355 - Comment: comment, 356 - Span: p.span(s), 394 + Account: account, 395 + Subdirectives: subs, 396 + Comment: comment, 397 + Span: p.span(s), 357 398 } 358 399 } 359 400 ··· 364 405 365 406 var commodity string 366 407 var commoditySpan token.Span 367 - var format *ast.Amount 408 + var format *ast.FormatSubDirective 368 409 369 410 switch p.cur.Type { 370 411 case token.COMMODITYMARK, token.TEXT, token.STRING: ··· 375 416 hadSpace := p.got(token.WHITESPACE) 376 417 p.skipWhitespace() 377 418 if p.got(token.INT) || p.got(token.DECIMAL) || p.got(token.TEXT) { 378 - format = p.parseAmount() 379 - format.Commodity = commodity 380 - format.CommoditySpan = commoditySpan 381 - format.CommodityPos = ast.CommodityBefore 382 - format.HasSpace = hadSpace 419 + amt := p.parseAmount() 420 + amt.Commodity = commodity 421 + amt.CommoditySpan = commoditySpan 422 + amt.CommodityPos = ast.CommodityBefore 423 + amt.HasSpace = hadSpace 424 + format = &ast.FormatSubDirective{Amount: *amt} 383 425 } 384 426 case token.INT, token.DECIMAL: 385 - format = p.parseAmount() 386 - commodity = format.Commodity 387 - commoditySpan = format.CommoditySpan 427 + amt := p.parseAmount() 428 + commodity = amt.Commodity 429 + commoditySpan = amt.CommoditySpan 430 + format = &ast.FormatSubDirective{Amount: *amt} 388 431 default: 389 432 p.errorf("expected commodity name or amount, got %s", p.cur.Type) 390 433 } ··· 393 436 p.errorf("expected commodity name, got %s", p.cur.Type) 394 437 } 395 438 439 + // hledger parity: an inline format amount must include a decimal mark 440 + if format != nil && format.Amount.QuantityFmt.Decimal == 0 { 441 + p.errorfAt(format.Amount.Span.Start, "Please include a decimal point or decimal comma in commodity directives, to help us parse correctly. It may be followed by zero or more decimal digits.") 442 + } 443 + 396 444 comment := p.parseOptInlineComment() 397 445 p.expectNewline() 398 446 447 + var blockComments []*ast.Comment 399 448 for p.got(token.INDENT) { 400 449 p.advance() 401 450 p.skipWhitespace() 402 - if p.got(token.TEXT) && p.cur.Literal == "format" { 403 - p.advance() 404 - p.skipWhitespace() 405 - format = p.parseAmount() 451 + if p.got(token.NEWLINE) || p.got(token.EOF) { 452 + // whitespace-only line: block continues 406 453 p.expectNewline() 407 454 continue 408 455 } 409 - for !p.got(token.NEWLINE) && !p.got(token.EOF) { 456 + switch { 457 + case p.got(token.TEXT) && p.cur.Literal == "format": 458 + kw := p.cur.Span 410 459 p.advance() 460 + p.skipWhitespace() 461 + amt := p.parseAmount() 462 + // hledger parity: the format symbol must match the declared commodity, 463 + // and the amount must include a decimal mark; the node is kept either 464 + // way so the printer can round-trip the input. 465 + if amt.Commodity != commodity { 466 + p.errorfAt(amt.Span.Start, "commodity directive symbol %q and format directive symbol %q should be the same", commodity, amt.Commodity) 467 + } else if amt.QuantityFmt.Decimal == 0 { 468 + p.errorfAt(amt.Span.Start, "Please include a decimal point or decimal comma in commodity directives, to help us parse correctly. It may be followed by zero or more decimal digits.") 469 + } 470 + c := p.parseOptInlineComment() 471 + p.expectNewline() 472 + format = &ast.FormatSubDirective{KeywordSpan: kw, Amount: *amt, Comment: c} 473 + case p.got(token.SEMICOLON): // comment line 474 + c := p.parseCommentRest(p.cur.Span) 475 + p.expectNewline() 476 + blockComments = append(blockComments, c) 477 + case p.got(token.TEXT) && isCommentMarker(p.cur.Literal): 478 + // '#', '%' and '*' lex as TEXT in directive mode; treat them as comment lines 479 + blockComments = append(blockComments, p.parseTextComment()) 480 + case p.got(token.TEXT): 481 + p.errorf("unknown subdirective %q", p.cur.Literal) 482 + p.skipToNewline() 483 + default: 484 + p.errorf("expected subdirective name, got %s", p.cur.Type) 485 + p.skipToNewline() 411 486 } 412 - p.expectNewline() 413 487 } 414 488 415 489 cd := &ast.CommodityDirective{ 416 490 Commodity: commodity, 417 491 CommoditySpan: commoditySpan, 492 + FormatSub: format, 493 + BlockComments: blockComments, 418 494 Comment: comment, 419 495 Span: p.span(s), 420 - } 421 - if format != nil { 422 - cd.Format = *format 423 496 } 424 497 return cd 425 498 } ··· 770 843 func (p *Parser) parseAmount() *ast.Amount { 771 844 s := p.cur.Span 772 845 amt := &ast.Amount{ 773 - QuantityFmt: ast.QuantityFormat{Decimal: '.'}, 846 + QuantityFmt: ast.QuantityFormat{}, 774 847 } 775 848 defer func() { 776 849 // The span covers from the first token to the start of the next unconsumed token. ··· 851 924 lit := p.cur.Literal 852 925 amt := &ast.Amount{ 853 926 IsExpr: true, 854 - QuantityFmt: ast.QuantityFormat{Decimal: '.'}, 927 + QuantityFmt: ast.QuantityFormat{}, 855 928 } 856 929 if len(lit) >= 2 && lit[0] == '(' && lit[len(lit)-1] == ')' { 857 930 amt.Expr = strings.Trim(lit[1:len(lit)-1], " \t") ··· 1193 1266 }) 1194 1267 } 1195 1268 1269 +// errorfAt records a parse error pointing at a single position. 1270 +func (p *Parser) errorfAt(pos token.Pos, format string, args ...any) { 1271 + p.errors = append(p.errors, &ast.ParseError{ 1272 + Span: token.Span{Start: pos, End: pos}, 1273 + Message: fmt.Sprintf(format, args...), 1274 + }) 1275 +} 1276 + 1277 +func isAccountSubdirective(name string) bool { 1278 + switch name { 1279 + case "alias", "type", "note": 1280 + return true 1281 + } 1282 + return false 1283 +} 1284 + 1285 +func isCommentMarker(s string) bool { 1286 + return len(s) > 0 && (s[0] == '#' || s[0] == '%' || s[0] == '*') 1287 +} 1288 + 1289 +// skipToNewline consumes the rest of the current line. 1290 +func (p *Parser) skipToNewline() { 1291 + for !p.got(token.NEWLINE) && !p.got(token.EOF) { 1292 + p.advance() 1293 + } 1294 + p.expectNewline() 1295 +} 1296 + 1297 +// parseSubdirectiveValue consumes the tokens after a subdirective keyword up 1298 +// to an inline comment or the end of the line, returning the raw text and its 1299 +// span (trimmed of surrounding whitespace). Single-token values return the 1300 +// lexer's literal, sharing the source backing without a copy. 1301 +func (p *Parser) parseSubdirectiveValue() (string, token.Span) { 1302 + var b strings.Builder 1303 + var first, last token.Span 1304 + var single, pendingWS string 1305 + for !p.got(token.SEMICOLON) && !p.got(token.NEWLINE) && !p.got(token.EOF) { 1306 + t := p.cur 1307 + if t.Type == token.WHITESPACE || t.Type == token.INDENT { 1308 + if first.Start.Offset > 0 { 1309 + pendingWS = t.Literal // only the last whitespace run matters 1310 + } 1311 + p.advance() 1312 + continue 1313 + } 1314 + if first.Start.Offset == 0 { 1315 + first, last = t.Span, t.Span 1316 + single = t.Literal 1317 + } else { 1318 + if single != "" { 1319 + b.WriteString(single) 1320 + single = "" 1321 + } 1322 + if pendingWS != "" { 1323 + b.WriteString(pendingWS) 1324 + pendingWS = "" 1325 + } 1326 + b.WriteString(t.Literal) 1327 + last = t.Span 1328 + } 1329 + p.advance() 1330 + } 1331 + if first.Start.Offset == 0 { 1332 + return "", token.Span{} 1333 + } 1334 + if single != "" { 1335 + return single, token.Span{Start: first.Start, End: last.End} 1336 + } 1337 + return strings.TrimSpace(b.String()), token.Span{Start: first.Start, End: last.End} 1338 +} 1339 + 1340 +// parseTextComment consumes a comment line whose marker ('#', '%' or '*') 1341 +// lexed as TEXT inside a directive block, building the comment from token 1342 +// literals. Tags are not extracted; the comment is one line. 1343 +func (p *Parser) parseTextComment() *ast.Comment { 1344 + s := p.cur.Span 1345 + marker := p.cur.Literal[0] 1346 + var b strings.Builder 1347 + b.WriteString(p.cur.Literal) 1348 + p.advance() 1349 + for !p.got(token.NEWLINE) && !p.got(token.EOF) { 1350 + b.WriteString(p.cur.Literal) 1351 + p.advance() 1352 + } 1353 + text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(b.String()), string(marker))) 1354 + span := p.span(s) // marker to line end, without the newline 1355 + p.expectNewline() 1356 + return &ast.Comment{Marker: marker, Text: text, Span: span} 1357 +} 1358 + 1196 1359 func isDirectiveKeyword(t token.Type) bool { 1197 1360 switch t { 1198 1361 case token.COMMENTKW, token.ACCOUNT, token.COMMODITY, token.INCLUDE, ··· 1263 1426 } 1264 1427 1265 1428 if len(seps) == 0 { 1266 - return ast.QuantityFormat{Decimal: '.', Thousands: 0, Precision: 0} 1429 + return ast.QuantityFormat{} 1267 1430 } 1268 1431 1269 1432 last := seps[len(seps)-1] 1270 1433 dec := lit[last] 1434 + if dec != '.' && dec != ',' { 1435 + // the last separator is a thousands mark; the literal has no decimal mark 1436 + dec = 0 1437 + } 1271 1438 var thou byte 1272 1439 if len(seps) > 1 { 1273 1440 thou = lit[seps[0]] 1274 - } else if dec == ' ' || dec == '_' || dec == '\'' { 1441 + } else if dec == 0 { 1275 1442 // single space/underscore/apostrophe is always thousands 1276 - thou = dec 1277 - dec = '.' 1443 + thou = lit[last] 1278 1444 } 1279 1445 1280 1446 // calculate precision when the last separator is a real decimal
M
journal/parser/parser_test.go
··· 41 41 "account directive", 42 42 "account directive with comment", 43 43 "account with subdirectives", 44 + "account subdirectives types", 44 45 "comodity directive", 45 46 "comodity directive word", 46 47 "comodity directive no space", 47 48 "commodity quantity first", 48 49 "commodity quantity after", 49 50 "commodity with subdirectives", 51 + "commodity format errors", 50 52 "payee directive", 51 53 "transaction", 52 54 "automated transaction", ··· 101 103 for _, tt := range tests { 102 104 t.Run(tt, func(t *testing.T) { 103 105 a := golden.Read(t, tt) 104 - l := lexer.New("j", string(a.Get("input"))) 106 + l := lexer.New("j", a.Get("input")) 105 107 f := New(l).ParseJournal() 106 108 golden.Assert(t, a, ast.Dump(f)) 107 109 }) ··· 144 146 f.Add([]byte{0xff, 0xfe, 0x00}) 145 147 146 148 f.Fuzz(func(t *testing.T, data []byte) { 147 - l := lexer.New("j", string(data)) 149 + l := lexer.New("j", data) 148 150 j := New(l).ParseJournal() 149 151 if j == nil { 150 152 t.Fatalf("nil journal for input %q", string(data))
A
journal/parser/testdata/account_subdirectives_types.txtar
··· 1 +-- input -- 2 +account expenses:food 3 + type X 4 + note grocery shopping ; what we spend 5 + alias food 6 + 7 +account assets:bank 8 + type asset 9 + # hash comment 10 + payee ^store$ 11 + alias bank 12 + type A ; duplicate type 13 + alias 14 + 15 +-- expect -- 16 +Journal 17 + AccountDirective j:1:1-6:0 18 + Account j:1:9-2:0 19 + SubAccount: "expenses" j:1:9-1:17 20 + SubAccount: "food" j:1:18-2:0 21 + Subdirective 22 + Name: "type" j:2:3-2:7 23 + Value: "X" j:2:8-3:0 24 + Subdirective 25 + Name: "note" j:3:3-3:7 26 + Value: "grocery shopping" j:3:8-3:24 27 + Comment j:3:26-4:0 28 + Marker: ";" 29 + Text: "what we spend" 30 + Subdirective 31 + Name: "alias" j:4:3-4:8 32 + Value: "food" j:4:9-5:0 33 + BlankLine j:6:0-6:1 34 + AccountDirective j:6:1-14:0 35 + Account j:6:9-7:0 36 + SubAccount: "assets" j:6:9-6:15 37 + SubAccount: "bank" j:6:16-7:0 38 + Subdirective 39 + Name: "type" j:7:3-7:7 40 + Value: "asset" j:7:8-8:0 41 + Comment j:8:3-9:0 42 + Marker: "#" 43 + Text: "hash comment" 44 + Subdirective 45 + Name: "alias" j:10:3-10:8 46 + Value: "bank" j:10:9-11:0 47 + Subdirective 48 + Name: "type" j:11:3-11:7 49 + Value: "A" j:11:8-11:9 50 + Comment j:11:11-12:0 51 + Marker: ";" 52 + Text: "duplicate type" 53 + Subdirective 54 + Name: "alias" j:12:3-13:0 55 + Value: "" 0:0-0:0 56 + BlankLine j:14:0-14:1 57 + Errors 58 + j:9:3-9:8: unknown subdirective "payee" 59 + j:13:0-14:0: expected value for subdirective "alias"
M
journal/parser/testdata/account_with_subdirectives.txtar
··· 8 8 Account j:1:9-2:0 9 9 SubAccount: "expenses" j:1:9-1:17 10 10 SubAccount: "food" j:1:18-2:0 11 + Subdirective 12 + Name: "note" j:2:3-2:7 13 + Value: "some note" j:2:8-3:0 14 + Subdirective 15 + Name: "alias" j:3:3-3:8 16 + Value: "food" j:3:9-3:13 17 + Comment j:3:15-4:0 18 + Marker: ";" 19 + Text: "this gets ignored"
M
journal/parser/testdata/automated_transaction.txtar
··· 39 39 HasSpace: false 40 40 IsExpr: true 41 41 Precision: 0 42 - Decimal: "." 43 42 Posting j:6:1-8:0 44 43 Account j:6:5-6:18 45 44 SubAccount: "assets" j:6:5-6:11 ··· 51 50 HasSpace: false 52 51 IsExpr: true 53 52 Precision: 0 54 - Decimal: "." 55 53 BlankLine j:8:0-8:1 56 54 AutomatedTransaction j:8:1-10:1 57 55 Expr: "income:salary" j:8:3-9:0 ··· 67 65 IsExpr: true 68 66 Expr: "amount * 0.5" 69 67 Precision: 0 70 - Decimal: "."
M
journal/parser/testdata/bad_between_good.txtar
··· 18 18 CommodityPos: Before 19 19 HasSpace: false 20 20 Precision: 0 21 - Decimal: "." 22 21 Posting j:4:1-5:1 23 22 Account j:4:5-4:16 24 23 SubAccount: "assets" j:4:5-4:11 ··· 29 28 CommodityPos: Before 30 29 HasSpace: false 31 30 Precision: 0 32 - Decimal: "." 33 31 Errors 34 32 j:3:5-3:7: expected account name, got ATAT
M
journal/parser/testdata/bad_posting_at_end.txtar
··· 21 21 CommodityPos: Before 22 22 HasSpace: false 23 23 Precision: 0 24 - Decimal: "." 25 24 BlankLine j:5:0-5:1 26 25 Transaction j:5:1-8:1 27 26 Date: 2024/01/02 ··· 36 35 CommodityPos: Before 37 36 HasSpace: false 38 37 Precision: 0 39 - Decimal: "." 40 38 Posting j:7:1-8:1 41 39 Account j:7:5-8:0 42 40 SubAccount: "assets" j:7:5-7:11
M
journal/parser/testdata/bad_then_next_transaction.txtar
··· 21 21 CommodityPos: Before 22 22 HasSpace: false 23 23 Precision: 0 24 - Decimal: "." 25 24 BlankLine j:5:0-5:1 26 25 Transaction j:5:1-8:1 27 26 Date: 2024/01/02 ··· 36 35 CommodityPos: Before 37 36 HasSpace: false 38 37 Precision: 0 39 - Decimal: "." 40 38 Posting j:7:1-8:1 41 39 Account j:7:5-8:0 42 40 SubAccount: "assets" j:7:5-7:11
A
journal/parser/testdata/commodity_format_errors.txtar
··· 1 +-- input -- 2 +commodity $ 3 + format 1.000,00 4 + 5 +commodity EUR 6 + format EUR 100.00 ; ok 7 + 8 +commodity $100 9 +-- expect -- 10 +Journal 11 + CommodityDirective j:1:1-4:0 12 + Commodity: "$" j:1:11-2:0 13 + Format j:2:3-2:9 14 + Amount j:2:10-3:0 15 + Quantity: 1000 16 + Commodity: "" 17 + CommodityPos: Before 18 + HasSpace: false 19 + Precision: 2 20 + Decimal: "," 21 + Thousands: "." 22 + BlankLine j:4:0-4:1 23 + CommodityDirective j:4:1-7:0 24 + Commodity: "EUR" j:4:11-5:0 25 + Format j:5:3-5:9 26 + Amount j:5:10-5:20 27 + Quantity: 100 28 + Commodity: "EUR" j:5:10-5:13 29 + CommodityPos: Before 30 + HasSpace: true 31 + Precision: 2 32 + Decimal: "." 33 + Comment j:5:22-6:0 34 + Marker: ";" 35 + Text: "ok" 36 + BlankLine j:7:0-7:1 37 + CommodityDirective j:7:1-8:1 38 + Commodity: "$" j:7:11-7:12 39 + Format inline 40 + Amount j:7:12-8:0 41 + Quantity: 100 42 + Commodity: "$" j:7:11-7:12 43 + CommodityPos: Before 44 + HasSpace: false 45 + Precision: 0 46 + Errors 47 + j:2:10-2:10: commodity directive symbol "$" and format directive symbol "" should be the same 48 + j:7:12-7:12: Please include a decimal point or decimal comma in commodity directives, to help us parse correctly. It may be followed by zero or more decimal digits.
M
journal/parser/testdata/commodity_quantity_after.txtar
··· 4 4 Journal 5 5 CommodityDirective j:1:1-2:1 6 6 Commodity: "UAH" j:1:11-1:14 7 - Amount j:1:15-2:0 8 - Quantity: 1000 9 - Commodity: "UAH" j:1:11-1:14 10 - CommodityPos: Before 11 - HasSpace: true 12 - Precision: 2 13 - Decimal: "." 14 - Thousands: "," 7 + Format inline 8 + Amount j:1:15-2:0 9 + Quantity: 1000 10 + Commodity: "UAH" j:1:11-1:14 11 + CommodityPos: Before 12 + HasSpace: true 13 + Precision: 2 14 + Decimal: "." 15 + Thousands: ","
M
journal/parser/testdata/commodity_quantity_first.txtar
··· 4 4 Journal 5 5 CommodityDirective j:1:1-2:1 6 6 Commodity: "UAH" j:1:20-2:0 7 - Amount j:1:11-2:0 8 - Quantity: 1000 9 - Commodity: "UAH" j:1:20-2:0 10 - CommodityPos: After 11 - HasSpace: true 12 - Precision: 2 13 - Decimal: "." 14 - Thousands: "," 7 + Format inline 8 + Amount j:1:11-2:0 9 + Quantity: 1000 10 + Commodity: "UAH" j:1:20-2:0 11 + CommodityPos: After 12 + HasSpace: true 13 + Precision: 2 14 + Decimal: "." 15 + Thousands: ","
M
journal/parser/testdata/commodity_with_subdirectives.txtar
··· 7 7 Journal 8 8 CommodityDirective j:1:1-5:0 9 9 Commodity: "UAH" j:1:11-2:0 10 - Amount j:2:10-3:0 11 - Quantity: 1000 12 - Commodity: "UAH" j:2:19-3:0 13 - CommodityPos: After 14 - HasSpace: true 15 - Precision: 2 16 - Decimal: "." 17 - Thousands: " " 10 + Format j:2:3-2:9 11 + Amount j:2:10-3:0 12 + Quantity: 1000 13 + Commodity: "UAH" j:2:19-3:0 14 + CommodityPos: After 15 + HasSpace: true 16 + Precision: 2 17 + Decimal: "." 18 + Thousands: " " 18 19 BlankLine j:5:0-5:1 20 + Errors 21 + j:3:3-3:7: unknown subdirective "note"
M
journal/parser/testdata/comodity_directive_no_space.txtar
··· 4 4 Journal 5 5 CommodityDirective j:1:1-2:1 6 6 Commodity: "$" j:1:11-1:12 7 - Amount j:1:12-2:0 8 - Quantity: 1000 9 - Commodity: "$" j:1:11-1:12 10 - CommodityPos: Before 11 - HasSpace: false 12 - Precision: 2 13 - Decimal: "." 7 + Format inline 8 + Amount j:1:12-2:0 9 + Quantity: 1000 10 + Commodity: "$" j:1:11-1:12 11 + CommodityPos: Before 12 + HasSpace: false 13 + Precision: 2 14 + Decimal: "."
M
journal/parser/testdata/illegal_between_transactions.txtar
··· 21 21 CommodityPos: Before 22 22 HasSpace: false 23 23 Precision: 0 24 - Decimal: "." 25 24 Posting j:3:1-4:1 26 25 Account j:3:5-4:0 27 26 SubAccount: "assets" j:3:5-3:11 ··· 40 39 CommodityPos: Before 41 40 HasSpace: false 42 41 Precision: 0 43 - Decimal: "." 44 42 Posting j:7:1-8:1 45 43 Account j:7:5-8:0 46 44 SubAccount: "assets" j:7:5-7:11
M
journal/parser/testdata/period_transaction_expressions.txtar
··· 34 34 CommodityPos: Before 35 35 HasSpace: false 36 36 Precision: 0 37 - Decimal: "." 38 37 Posting j:4:1-6:0 39 38 Account j:4:5-5:0 40 39 SubAccount: "assets" j:4:5-4:11 ··· 56 55 CommodityPos: Before 57 56 HasSpace: false 58 57 Precision: 0 59 - Decimal: "." 60 58 Posting j:8:1-10:0 61 59 Account j:8:5-9:0 62 60 SubAccount: "assets" j:8:5-8:11 ··· 77 75 CommodityPos: Before 78 76 HasSpace: false 79 77 Precision: 0 80 - Decimal: "." 81 78 Posting j:12:1-14:0 82 79 Account j:12:5-13:0 83 80 SubAccount: "assets" j:12:5-12:11 ··· 98 95 CommodityPos: Before 99 96 HasSpace: false 100 97 Precision: 0 101 - Decimal: "." 102 98 Posting j:16:1-18:0 103 99 Account j:16:5-17:0 104 100 SubAccount: "assets" j:16:5-16:11 ··· 120 116 CommodityPos: Before 121 117 HasSpace: false 122 118 Precision: 0 123 - Decimal: "." 124 119 Posting j:20:1-21:1 125 120 Account j:20:5-21:0 126 121 SubAccount: "assets" j:20:5-20:11
M
journal/parser/testdata/transaction_with_balance_assignment.txtar
··· 16 16 CommodityPos: Before 17 17 HasSpace: false 18 18 Precision: 0 19 - Decimal: "." 20 19 BalanceAssertion j:2:12-3:0 21 20 IsStrict: false 22 21 IsInclusive: false ··· 27 26 CommodityPos: Before 28 27 HasSpace: false 29 28 Precision: 0 30 - Decimal: "." 31 29 BlankLine j:4:0-4:1
M
journal/parser/testdata/transaction_with_cost_and_assertion.txtar
··· 26 26 CommodityPos: After 27 27 HasSpace: true 28 28 Precision: 0 29 - Decimal: "." 30 29 BalanceAssertion j:2:34-3:0 31 30 IsStrict: false 32 31 IsInclusive: false ··· 37 36 CommodityPos: After 38 37 HasSpace: true 39 38 Precision: 0 40 - Decimal: "." 41 39 Posting j:3:1-5:0 42 40 Account j:3:2-4:0 43 41 SubAccount: "assets" j:3:2-3:8
M
journal/parser/testdata/transaction_with_costs.txtar
··· 38 38 CommodityPos: After 39 39 HasSpace: true 40 40 Precision: 0 41 - Decimal: "." 42 41 Posting j:3:1-5:0 43 42 Account j:3:2-4:0 44 43 SubAccount: "assets" j:3:2-3:8 ··· 66 65 CommodityPos: After 67 66 HasSpace: true 68 67 Precision: 0 69 - Decimal: "." 70 68 Posting j:7:1-9:0 71 69 Account j:7:2-8:0 72 70 SubAccount: "assets" j:7:2-7:8 ··· 94 92 CommodityPos: After 95 93 HasSpace: true 96 94 Precision: 0 97 - Decimal: "." 98 95 BalanceAssertion j:10:34-11:0 99 96 IsStrict: false 100 97 IsInclusive: false ··· 113 110 CommodityPos: After 114 111 HasSpace: true 115 112 Precision: 0 116 - Decimal: "." 117 113 Posting j:11:1-13:0 118 114 Account j:11:2-12:0 119 115 SubAccount: "assets" j:11:2-11:8 ··· 133 129 CommodityPos: After 134 130 HasSpace: true 135 131 Precision: 0 136 - Decimal: "." 137 132 Cost(unit) j:14:26-15:0 138 133 Amount j:14:28-15:0 139 134 Quantity: 7.53
M
journal/parser/testdata/transaction_with_inclusive_balance_assertion.txtar
··· 18 18 CommodityPos: Before 19 19 HasSpace: false 20 20 Precision: 0 21 - Decimal: "." 22 21 BalanceAssertion j:2:11-3:0 23 22 IsStrict: false 24 23 IsInclusive: true ··· 29 28 CommodityPos: Before 30 29 HasSpace: false 31 30 Precision: 0 32 - Decimal: "." 33 31 Posting j:3:1-4:1 34 32 Account j:3:5-3:6 35 33 SubAccount: "b" j:3:5-3:6 ··· 39 37 CommodityPos: Before 40 38 HasSpace: false 41 39 Precision: 0 42 - Decimal: "." 43 40 BalanceAssertion j:3:11-4:0 44 41 IsStrict: false 45 42 IsInclusive: true ··· 50 47 CommodityPos: Before 51 48 HasSpace: false 52 49 Precision: 0 53 - Decimal: "." 54 50 Posting j:4:1-6:0 55 51 Account j:4:5-4:6 56 52 SubAccount: "c" j:4:5-4:6 ··· 60 56 CommodityPos: Before 61 57 HasSpace: false 62 58 Precision: 0 63 - Decimal: "." 64 59 BalanceAssertion j:4:11-5:0 65 60 IsStrict: true 66 61 IsInclusive: true ··· 71 66 CommodityPos: Before 72 67 HasSpace: false 73 68 Precision: 0 74 - Decimal: "." 75 69 BlankLine j:6:0-6:1
M
journal/parser/testdata/transaction_with_scientific_amounts.txtar
··· 18 18 CommodityPos: After 19 19 HasSpace: true 20 20 Precision: 0 21 - Decimal: "." 22 21 Posting j:3:1-4:1 23 22 Account j:3:5-3:6 24 23 SubAccount: "b" j:3:5-3:6 ··· 28 27 CommodityPos: Before 29 28 HasSpace: true 30 29 Precision: 0 31 - Decimal: "." 32 30 Posting j:4:1-6:0 33 31 Account j:4:5-4:6 34 32 SubAccount: "c" j:4:5-4:6
M
journal/parser/testdata/transaction_with_spaced_account_name.txtar
··· 17 17 CommodityPos: Before 18 18 HasSpace: false 19 19 Precision: 0 20 - Decimal: "." 21 20 BalanceAssertion j:2:34-3:0 22 21 IsStrict: false 23 22 IsInclusive: false ··· 28 27 CommodityPos: Before 29 28 HasSpace: false 30 29 Precision: 0 31 - Decimal: "." 32 30 Posting j:3:1-5:0 33 31 Account j:3:5-4:0 34 32 SubAccount: "equity" j:3:5-3:11
M
journal/parser/testdata/transaction_with_strange_commodity_symbols.txtar
··· 23 23 CommodityPos: After 24 24 HasSpace: true 25 25 Precision: 0 26 - Decimal: "." 27 26 Posting j:4:1-6:0 28 27 Account j:4:3-5:0 29 28 SubAccount: "asdf2" j:4:3-5:0 ··· 40 39 CommodityPos: After 41 40 HasSpace: true 42 41 Precision: 0 43 - Decimal: "." 44 42 Posting j:8:1-9:1 45 43 Account j:8:3-9:0 46 44 SubAccount: "asdf2" j:8:3-9:0
M
journal/parser/testdata/transaction_with_trailing_indent.txtar
··· 22 22 CommodityPos: Before 23 23 HasSpace: false 24 24 Precision: 0 25 - Decimal: "." 26 25 Posting j:4:1-6:0 27 26 Account j:4:5-4:25 28 27 SubAccount: "assets" j:4:5-4:11 ··· 34 33 CommodityPos: Before 35 34 HasSpace: false 36 35 Precision: 0 37 - Decimal: "." 38 36 BlankLine j:6:0-6:1
M
journal/parser/testdata/transaction_with_virtual_accounts.txtar
··· 19 19 CommodityPos: After 20 20 HasSpace: true 21 21 Precision: 0 22 - Decimal: "." 23 22 Posting j:3:1-4:1 24 23 Type: balanced virtual 25 24 Account j:3:3-3:17 ··· 31 30 CommodityPos: After 32 31 HasSpace: true 33 32 Precision: 0 34 - Decimal: "." 35 33 Posting j:4:1-5:1 36 34 Account j:4:2-5:0 37 35 SubAccount: "something" j:4:2-4:11
M
journal/parser/testdata/virtual_postings_with_statuses.txtar
··· 27 27 CommodityPos: Before 28 28 HasSpace: false 29 29 Precision: 0 30 - Decimal: "." 31 30 Posting j:3:1-5:0 32 31 Type: unbalanced virtual 33 32 Account j:3:4-3:15 ··· 39 38 CommodityPos: Before 40 39 HasSpace: false 41 40 Precision: 0 42 - Decimal: "." 43 41 BlankLine j:5:0-5:1 44 42 Transaction j:5:1-9:0 45 43 Date: 2024/01/01 ··· 56 54 CommodityPos: Before 57 55 HasSpace: false 58 56 Precision: 0 59 - Decimal: "." 60 57 Posting j:7:1-9:0 61 58 Type: unbalanced virtual 62 59 Account j:7:6-7:17 ··· 68 65 CommodityPos: Before 69 66 HasSpace: false 70 67 Precision: 0 71 - Decimal: "." 72 68 BlankLine j:9:0-9:1 73 69 Transaction j:9:1-12:1 74 70 Date: 2024/01/01 ··· 85 81 CommodityPos: Before 86 82 HasSpace: false 87 83 Precision: 0 88 - Decimal: "." 89 84 Posting j:11:1-12:1 90 85 Type: unbalanced virtual 91 86 Status: "*" ··· 98 93 CommodityPos: Before 99 94 HasSpace: false 100 95 Precision: 0 101 - Decimal: "."
M
journal/printer/directives.go
··· 17 17 p.buf.WriteString("account ") 18 18 p.buf.WriteString(a.Account.String()) 19 19 p.writeInlineComment(a.Comment) 20 + for _, sd := range a.Subdirectives { 21 + p.buf.WriteByte('\n') 22 + p.buf.WriteString(p.indent) 23 + if sd.Name == "" { 24 + p.writeComment(sd.Comment) 25 + continue 26 + } 27 + p.buf.WriteString(sd.Name) 28 + p.buf.WriteByte(' ') 29 + p.buf.WriteString(sd.Value) 30 + p.writeInlineComment(sd.Comment) 31 + } 20 32 } 21 33 22 34 func (p *printer) writeCommodityDirective(c *ast.CommodityDirective) { 23 35 p.buf.WriteString("commodity ") 24 - if c.Format.Quantity.IsZero() { 36 + if c.FormatSub == nil { 25 37 p.buf.WriteString(c.Commodity) 26 38 p.writeInlineComment(c.Comment) 27 39 return 28 40 } 29 - prec := max(c.Format.QuantityFmt.Precision, 2) 30 - switch c.Format.CommodityPos { 41 + if c.FormatSub.KeywordSpan.End.Offset == 0 { 42 + // inline form: "commodity $1,000.00" 43 + p.writeCommodityAmount(&c.FormatSub.Amount) 44 + p.writeInlineComment(c.Comment) 45 + return 46 + } 47 + p.buf.WriteString(c.Commodity) 48 + p.writeInlineComment(c.Comment) 49 + p.buf.WriteByte('\n') 50 + p.buf.WriteString(p.indent) 51 + p.buf.WriteString("format ") 52 + p.writeCommodityAmount(&c.FormatSub.Amount) 53 + p.writeInlineComment(c.FormatSub.Comment) 54 + p.writeBlockComments(c.BlockComments) 55 +} 56 + 57 +// writeCommodityAmount renders a commodity format amount, preserving the 58 +// original commodity placement instead of the config's CommodityPos. 59 +func (p *printer) writeCommodityAmount(a *ast.Amount) { 60 + prec := max(a.QuantityFmt.Precision, 2) 61 + if a.Commodity == "" { 62 + p.writeDecimal(a.Quantity, a.QuantityFmt, prec) 63 + return 64 + } 65 + switch a.CommodityPos { 31 66 case ast.CommodityBefore: 32 - p.buf.WriteString(c.Format.Commodity) 33 - if c.Format.HasSpace { 67 + p.buf.WriteString(a.Commodity) 68 + if a.HasSpace { 34 69 p.buf.WriteByte(' ') 35 70 } 36 - p.writeDecimal(c.Format.Quantity, c.Format.QuantityFmt, prec) 71 + p.writeDecimal(a.Quantity, a.QuantityFmt, prec) 37 72 case ast.CommodityAfter: 38 - p.writeDecimal(c.Format.Quantity, c.Format.QuantityFmt, prec) 39 - if c.Format.HasSpace { 73 + p.writeDecimal(a.Quantity, a.QuantityFmt, prec) 74 + if a.HasSpace { 40 75 p.buf.WriteByte(' ') 41 76 } 42 - p.buf.WriteString(c.Format.Commodity) 77 + p.buf.WriteString(a.Commodity) 78 + } 79 +} 80 + 81 +// writeBlockComments writes indented comment lines inside a directive block. 82 +func (p *printer) writeBlockComments(cs []*ast.Comment) { 83 + for _, c := range cs { 84 + p.buf.WriteByte('\n') 85 + p.buf.WriteString(p.indent) 86 + p.writeComment(c) 43 87 } 44 - p.writeInlineComment(c.Comment) 45 88 } 46 89 47 90 func (p *printer) writeAliasDirective(a *ast.AliasDirective) {
M
journal/printer/testdata/directives.txtar
··· 1 1 -- input -- 2 2 ; Directives 3 3 account Assets:Cash ; cash account 4 +account Assets:Bank 5 + type A 6 + note main bank account ; bank note 7 + alias bank 8 + alias checking ; checking alias 9 + ; some note 10 + # hash comment 4 11 commodity $ 5 12 commodity UAH 13 +commodity EUR 14 + format EUR 1.000,00 ; euro format 15 +commodity CAD 16 + format 1 000.00 CAD 6 17 commodity 1000.00 USD 7 18 commodity $1000.00 8 19 commodity 1,000.00 UAH ··· 39 50 -- expect -- 40 51 ; Directives 41 52 account Assets:Cash ; cash account 53 +account Assets:Bank 54 + type A 55 + note main bank account ; bank note 56 + alias bank 57 + alias checking ; checking alias 58 + ; some note 59 + # hash comment 42 60 commodity $ 43 61 commodity UAH 62 +commodity EUR 63 + format EUR 1.000,00 ; euro format 64 +commodity CAD 65 + format 1 000.00 CAD 44 66 commodity 1000.00 USD 45 67 commodity $1000.00 46 68 commodity 1,000.00 UAH
A
journal/testdata/journals/subdirectives.journal
··· 1 +; subdirectives coverage 2 +; 3 +; account directive blocks: alias/type/note subdirectives and comment lines; 4 +; commodity directive blocks: format subdirectives (inline and block forms) 5 +; and comment lines 6 + 7 +commodity $ 8 +commodity $0.00 9 +commodity EUR 10 + format EUR 1.000,00 11 +commodity UAH 12 + format 1 000.00 UAH ; hryvnia display format 13 + ; block comment inside a commodity block 14 +commodity 1,000.00 USD 15 + 16 +account assets:bank 17 + alias bank 18 +account assets:checking 19 + type C 20 + alias checking ; main checking account 21 + ; used for daily expenses 22 + note main checking account 23 +account equity:opening 24 +account expenses:food 25 + alias food 26 + alias groceries ; second alias for the same account 27 + 28 +2024-01-01 Opening balances 29 + assets:bank $1000.00 30 + assets:checking €200.00 31 + equity:opening 32 + 33 +2024-01-02 Grocery store 34 + expenses:food 50.00 UAH 35 + assets:checking