63 files changed,
2011 insertions(+),
241 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-07-31 16:41:01 +0300
Authored at:
2026-07-29 20:09:13 +0300
Change ID:
ykpklkqsnuurvtvwqlktxzrotokxqqvp
Parent:
1f3a173
jump to
A
internal/lsp/lsputil/utf16.go
··· 1 +package lsputil 2 + 3 +import "unicode/utf8" 4 + 5 +// Utf16Col returns the UTF-16 code unit column (0-based) for a byte offset 6 +// within the given line content (without newline). 7 +func Utf16Col(line string, byteOffset int) int { 8 + if byteOffset <= 0 { 9 + return 0 10 + } 11 + if byteOffset > len(line) { 12 + byteOffset = len(line) 13 + } 14 + col := 0 15 + for i := 0; i < byteOffset; { 16 + r, size := utf8.DecodeRuneInString(line[i:]) 17 + if r == utf8.RuneError && size <= 1 { 18 + break 19 + } 20 + col += utf16Len(r) 21 + i += size 22 + } 23 + return col 24 +} 25 + 26 +// Utf16Len returns the UTF-16 code unit length of content[offset:end]. 27 +func Utf16Len(content string, offset, end int) int { 28 + if offset < 0 { 29 + offset = 0 30 + } 31 + if end > len(content) { 32 + end = len(content) 33 + } 34 + if offset >= end { 35 + return 0 36 + } 37 + n := 0 38 + for i := offset; i < end; { 39 + r, size := utf8.DecodeRuneInString(content[i:]) 40 + if r == utf8.RuneError && size <= 1 { 41 + break 42 + } 43 + n += utf16Len(r) 44 + i += size 45 + } 46 + return n 47 +} 48 + 49 +// LineCol converts a byte offset in content to 0-based line number and 50 +// UTF-16 code unit column. 51 +func LineCol(content string, offset int) (line int, col int) { 52 + if offset <= 0 { 53 + return 0, 0 54 + } 55 + if offset > len(content) { 56 + offset = len(content) 57 + } 58 + lineStart := 0 59 + lineNum := 0 60 + for i := 0; i < len(content); { 61 + r, size := utf8.DecodeRuneInString(content[i:]) 62 + if r == utf8.RuneError && size <= 1 { 63 + break 64 + } 65 + if r == '\n' { 66 + if offset < i+size { 67 + col = Utf16Col(content[lineStart:offset], offset-lineStart) 68 + return lineNum, col 69 + } 70 + lineStart = i + size 71 + lineNum++ 72 + i += size 73 + continue 74 + } 75 + if r == '\r' { 76 + // skip \r\n 77 + if i+1 < len(content) && content[i+1] == '\n' { 78 + size++ 79 + } 80 + if offset < i+size { 81 + col = Utf16Col(content[lineStart:offset], offset-lineStart) 82 + return lineNum, col 83 + } 84 + lineStart = i + size 85 + lineNum++ 86 + i += size 87 + continue 88 + } 89 + if i+size > offset { 90 + col = Utf16Col(content[lineStart:offset], offset-lineStart) 91 + return lineNum, col 92 + } 93 + i += size 94 + } 95 + col = Utf16Col(content[lineStart:], len(content)-lineStart) 96 + return lineNum, col 97 +} 98 + 99 +// utf16Len returns the number of UTF-16 code units for a rune. 100 +func utf16Len(r rune) int { 101 + if r >= 0x10000 && r <= 0x10FFFF { 102 + return 2 103 + } 104 + return 1 105 +}
A
internal/lsp/lsputil/utf16_test.go
··· 1 +package lsputil 2 + 3 +import "testing" 4 + 5 +func TestUtf16Col_Basic(t *testing.T) { 6 + col := Utf16Col("hello", 3) 7 + if col != 3 { 8 + t.Errorf("Utf16Col for ASCII = %d, want 3", col) 9 + } 10 +} 11 + 12 +func TestUtf16Col_Cyrillic(t *testing.T) { 13 + // "Привет" is 6 Cyrillic chars = 12 bytes, 6 UTF-16 code units 14 + line := "Привет" 15 + col := Utf16Col(line, len(line)) 16 + if col != 6 { 17 + t.Errorf("Utf16Col for 'Привет' = %d, want 6", col) 18 + } 19 +} 20 + 21 +func TestUtf16Len_ASCII(t *testing.T) { 22 + l := Utf16Len("hello world", 0, 5) 23 + if l != 5 { 24 + t.Errorf("Utf16Len = %d, want 5", l) 25 + } 26 +} 27 + 28 +func TestUtf16Len_Cyrillic(t *testing.T) { 29 + // "Привет" = 6 UTF-16 code units 30 + l := Utf16Len("Привет мир", 0, 12) 31 + if l != 6 { 32 + t.Errorf("Utf16Len for 'Привет' = %d, want 6", l) 33 + } 34 +} 35 + 36 +func TestLineCol_Basic(t *testing.T) { 37 + line, col := LineCol("hello\nworld", 11) 38 + if line != 1 || col != 5 { 39 + t.Errorf("LineCol = (%d,%d), want (1,5)", line, col) 40 + } 41 +} 42 + 43 +func TestLineCol_FirstLine(t *testing.T) { 44 + line, col := LineCol("hello world", 5) 45 + if line != 0 || col != 5 { 46 + t.Errorf("LineCol = (%d,%d), want (0,5)", line, col) 47 + } 48 +} 49 + 50 +func TestLineCol_LineStart(t *testing.T) { 51 + // offset at the first char of line 1 must be (1,0), not the tail of line 0 52 + line, col := LineCol("ab\ncd", 3) 53 + if line != 1 || col != 0 { 54 + t.Errorf("LineCol(3) = (%d,%d), want (1,0)", line, col) 55 + } 56 + // newline char itself belongs to the line it ends 57 + line, col = LineCol("ab\ncd", 2) 58 + if line != 0 || col != 2 { 59 + t.Errorf("LineCol(2) = (%d,%d), want (0,2)", line, col) 60 + } 61 +} 62 + 63 +func TestLineCol_BlankLine(t *testing.T) { 64 + // "a\n\nb": line 1 is blank; offset 3 is the start of line 2 65 + line, col := LineCol("a\n\nb", 3) 66 + if line != 2 || col != 0 { 67 + t.Errorf("LineCol(3) = (%d,%d), want (2,0)", line, col) 68 + } 69 + line, col = LineCol("a\n\nb", 2) 70 + if line != 1 || col != 0 { 71 + t.Errorf("LineCol(2) = (%d,%d), want (1,0)", line, col) 72 + } 73 +} 74 + 75 +func TestLineCol_CRLF(t *testing.T) { 76 + line, col := LineCol("ab\r\ncd", 4) 77 + if line != 1 || col != 0 { 78 + t.Errorf("LineCol(4) = (%d,%d), want (1,0)", line, col) 79 + } 80 + line, col = LineCol("ab\r\ncd", 2) 81 + if line != 0 || col != 2 { 82 + t.Errorf("LineCol(2) = (%d,%d), want (0,2)", line, col) 83 + } 84 +}
M
internal/lsp/server.go
··· 11 11 "olexsmir.xyz/clerk/internal/analyzer" 12 12 "olexsmir.xyz/clerk/internal/linter" 13 13 "olexsmir.xyz/clerk/journal" 14 + "olexsmir.xyz/clerk/journal/ast" 14 15 "olexsmir.xyz/clerk/journal/printer" 15 16 ) 16 17 ··· 38 39 text string 39 40 version int32 40 41 languageID protocol.LanguageKind 42 + journal *ast.Journal // cached parse; nil on parse failure 41 43 } 42 44 43 45 func (s *server) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) { ··· 51 53 TextDocumentSync: &protocol.TextDocumentSyncOptions{ 52 54 OpenClose: new(true), 53 55 Change: new(protocol.TextDocumentSyncKindFull), 56 + }, 57 + SemanticTokensProvider: &protocol.SemanticTokensOptions{ 58 + Legend: getSemanticTokensLegend(), 59 + Range: protocol.Boolean(true), 60 + Full: protocol.Boolean(true), 54 61 }, 55 62 }, 56 63 }, nil
A
internal/lsp/testdata/semantic-directives.txtar
··· 1 +-- in.journal -- 2 +account expenses:food 3 +account expenses:food ; note 4 + 5 +alias old = new 6 +alias old = new ; alias note 7 + 8 +payee Some Store 9 +payee Some Store ; payee note 10 + 11 +tag foo 12 +tag foo ; tag note 13 + 14 +year 2024 15 +year 2024 ; year note 16 + 17 +Y 2024 18 +Y 2024 ; Y note 19 + 20 +apply account expenses 21 +end apply 22 +apply account expenses ; apply note 23 +end apply ; end note 24 + 25 +apply tag hashtag 26 +end apply 27 +apply tag hashtag ; apply tag note 28 +end apply ; end note 29 + 30 +apply fixed CAD $0.90 31 +end apply 32 +apply fixed CAD $0.90 ; apply fixed note 33 +end apply ; end note 34 + 35 +N ignored 36 +N ignored ; N note 37 +N $ 38 +N $ ; N dollar note 39 + 40 +D $1,000.00 41 +D $1,000.00 ; D note 42 + 43 +P 2024-01-15 USD 1.05 44 +P 2024-01-15 USD 1.05 ; P note 45 +P 2024-01-01 12:00:00 USD 40.50 46 +P 2024-01-01 12:00:00 USD 40.50 ; P time note 47 + 48 +C 100 USD = 90 EUR 49 +C 100 USD = 90 EUR ; C note 50 + 51 +decimal-mark . 52 +decimal-mark . ; decimal note 53 + 54 +include foo.journal 55 +include foo.journal ; include note 56 + 57 +commodity USD 58 +commodity USD ; commodity note 59 + 60 +commodity INR 61 + format INR 1,00,00,000.00 62 +commodity INR ; commodity INR note 63 + format INR 1,00,00,000.00 64 + 65 +commodity 1,000.00 UAH 66 +commodity 1,000.00 UAH ; commodity amount note 67 +commodity UAH 68 + format 1 000.00 UAH 69 +commodity UAH ; commodity UAH note 70 + format 1 000.00 UAH 71 + 72 +-- expect -- 73 +0:0+7 keyword 74 +0:8+13 namespace 75 +1:0+7 keyword 76 +1:8+13 namespace 77 +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 109 +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 127 +28:0+5 keyword 128 +28:6+15 property 129 +29:0+3 keyword 130 +29:4+5 property 131 +30:0+5 keyword 132 +30:6+15 property 133 +30:23+18 comment 134 +31:0+3 keyword 135 +31:4+5 property 136 +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 163 +43:0+1 keyword 164 +43:2+10 class 165 +43:13+8 class 166 +43:22+3 type 167 +43:26+5 number 168 +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 174 +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 216 +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
A
internal/lsp/testdata/semantic-journal.txtar
··· 1 +-- in.journal -- 2 +; a comment 3 +; 4 +# hash comment 5 +% percent comment 6 +comment 7 +block text 8 +end comment 9 +comment tag:hidden 10 +stuff 11 +end 12 + 13 +* 2024-01-15 comment 14 +2024-01-15 real 15 + 16 +2024/01/01=2024/01/02 groceries 17 + ! assets:cash $10 18 + * (income:gift) $-10 19 + 20 +2024.01.01 "quoted payee" 21 + expenses:food 10.00 "EUR" 22 + assets:checking 23 + 24 +2024-01-01 groceries 25 + expenses:food 1E-6 BTC 26 + expenses:supplies EUR 1E3 27 + expenses:rest -2.5e-2 $ 28 + expenses:meals 1 000.00 UAH 29 + expenses:gifts 1'000.00 USD 30 + expenses:donuts -2_000.00 USD 31 + expenses:food €10.00 32 + expenses:food £5.00 33 + expenses:food ₹700.00 34 + expenses:account name 20.00 35 + assets:cash 36 + 37 +2024-01-15 t 38 + a $10 39 + b $-10 40 + 41 +2024-01-15 * (CODE) Cafe | lunch ; weekly treat 42 + ; header note 43 + expenses:food:cafe $12.50 44 + expenses:food $-2.50 ; discount 45 + assets:cash 46 + [virtual:budget] $20 47 + (virtual:unbalanced) $5 48 + assets:checking 50 = 100 49 + assets:savings 10 == 100 50 + assets:brokerage 5 := 100 51 + ; continuation comment 52 + 53 +2024-01-16 ! todo ; inline note 54 + expenses:rent 55 + assets:checking $800 56 + 57 +2024-01-17 * done 58 + a $1 = $100 59 + b $2 == $100 60 + c $3 =* $100 61 + d $4 ==* $100 62 + e $5 := $100 63 + 64 +2024-01-18 test 65 + a 10 @ 5 66 + b 10 @@ 50 67 + c 50 USD 68 + d 10 @ 5 = 100 69 + e 10 === 10 70 + f 10 @@ 5 =* 100 71 + g 10 = 100 @ 5 72 + 73 +2024-01-19 payee | note 74 +2024-01-20 (CODE) payee 75 + 76 +2020-02-12 * asdf 77 + assets 78 + expenses ; comment text 79 + 80 +2020-02-14 * more 81 + 82 +2024-01-15 午餐 83 +; Привет мир 84 +2024-01-15 Такси 85 + 86 +~ monthly 87 + a $10 88 + 89 +~ monthly from 2023-04-15 to 2023-06-16 90 + expenses:utilities $400 91 + assets:bank:checking 92 + 93 +~ monthly Next year blah blah ; period note 94 + expenses:food $100 95 + assets:checking 96 + 97 += expr 98 + a $10 99 + 100 += ^income 101 + (liabilities:tax) *.33 102 + 103 += expenses:gifts 104 + budget:gifts *-1 105 + assets:budget *1 106 + 107 += income:salary 108 + budget:savings (amount * 0.5) 109 + 110 += ^gifts ; automated note 111 + ; automated header 112 + expenses:gifts $25.50 113 + assets:cash 114 + 115 +2024-01-21 cost with commodity 116 + a 10 @ 5 EUR 117 + b 10 @@ 5 USD 118 + c $-5.00 @ 0.9 BTC 119 + 120 +-- expect -- 121 +0:0+12 comment 122 +1:0+2 comment 123 +2:0+15 comment 124 +3:0+18 comment 125 +4:0+31 comment 126 +7:0+29 comment 127 +11:0+21 comment 128 +12:0+10 class 129 +12:11+4 property 130 +14:0+10 class 131 +14:11+10 class 132 +14:22+9 property 133 +15:2+2 operator 134 +15:4+11 namespace 135 +15:17+1 type 136 +15:18+2 number 137 +16:2+2 operator 138 +16:4+1 operator abstract 139 +16:5+11 namespace 140 +16:16+1 operator abstract 141 +16:19+1 type 142 +16:20+3 number negative 143 +18:0+10 class 144 +18:11+14 property 145 +19:1+13 namespace 146 +19:16+5 number 147 +19:22+5 type 148 +20:1+15 namespace 149 +22:0+10 class 150 +22:11+9 property 151 +23:2+13 namespace 152 +23:17+4 number 153 +23:22+3 type 154 +24:2+17 namespace 155 +24:21+3 type 156 +24:25+3 number 157 +25:2+13 namespace 158 +25:17+7 number negative 159 +25:25+1 type 160 +26:2+14 namespace 161 +26:18+8 number 162 +26:27+3 type 163 +27:2+14 namespace 164 +27:18+8 number 165 +27:27+3 type 166 +28:2+15 namespace 167 +28:19+9 number negative 168 +28:29+3 type 169 +29:2+13 namespace 170 +29:17+1 type 171 +29:18+5 number 172 +30:2+13 namespace 173 +30:17+1 type 174 +30:18+4 number 175 +31:2+13 namespace 176 +31:17+1 type 177 +31:18+6 number 178 +32:2+21 namespace 179 +32:25+5 number 180 +33:2+11 namespace 181 +35:0+10 class 182 +35:11+1 property 183 +36:4+1 namespace 184 +36:7+1 type 185 +36:8+2 number 186 +37:4+1 namespace 187 +37:7+1 type 188 +37:8+3 number negative 189 +39:0+10 class 190 +39:11+2 operator 191 +39:13+6 string 192 +39:20+4 property 193 +39:27+6 property 194 +39:33+14 comment 195 +40:4+14 comment 196 +41:4+18 namespace 197 +41:26+1 type 198 +41:27+5 number 199 +42:4+13 namespace 200 +42:26+1 type 201 +42:27+5 number negative 202 +42:34+10 comment 203 +43:4+11 namespace 204 +44:4+1 operator abstract 205 +44:5+14 namespace 206 +44:19+1 operator abstract 207 +44:26+1 type 208 +44:27+2 number 209 +45:4+1 operator abstract 210 +45:5+18 namespace 211 +45:23+1 operator abstract 212 +45:26+1 type 213 +45:27+1 number 214 +46:4+15 namespace 215 +46:26+2 number 216 +46:29+1 operator 217 +46:31+3 number 218 +47:4+14 namespace 219 +47:26+2 number 220 +47:29+2 operator 221 +47:32+3 number 222 +48:4+16 namespace 223 +48:26+1 number 224 +48:28+2 operator 225 +48:31+3 number 226 +49:4+23 comment 227 +51:0+10 class 228 +51:11+2 operator 229 +51:13+4 property 230 +51:19+13 comment 231 +52:4+13 namespace 232 +53:4+15 namespace 233 +53:21+1 type 234 +53:22+3 number 235 +55:0+10 class 236 +55:11+2 operator 237 +55:13+4 property 238 +56:4+1 namespace 239 +56:7+1 type 240 +56:8+1 number 241 +56:10+1 operator 242 +56:12+1 type 243 +56:13+3 number 244 +57:4+1 namespace 245 +57:7+1 type 246 +57:8+1 number 247 +57:10+2 operator 248 +57:13+1 type 249 +57:14+3 number 250 +58:4+1 namespace 251 +58:7+1 type 252 +58:8+1 number 253 +58:10+2 operator 254 +58:13+1 type 255 +58:14+3 number 256 +59:4+1 namespace 257 +59:7+1 type 258 +59:8+1 number 259 +59:10+3 operator 260 +59:14+1 type 261 +59:15+3 number 262 +60:4+1 namespace 263 +60:7+1 type 264 +60:8+1 number 265 +60:10+2 operator 266 +60:13+1 type 267 +60:14+3 number 268 +62:0+10 class 269 +62:11+4 property 270 +63:2+1 namespace 271 +63:5+2 number 272 +63:8+1 operator 273 +63:10+1 number 274 +64:2+1 namespace 275 +64:5+2 number 276 +64:8+2 operator 277 +64:11+2 number 278 +65:2+1 namespace 279 +65:5+2 number 280 +65:8+3 type 281 +66:2+1 namespace 282 +66:5+2 number 283 +66:8+1 operator 284 +66:10+1 number 285 +66:12+1 operator 286 +66:14+3 number 287 +67:2+1 namespace 288 +67:5+2 number 289 +67:8+3 operator 290 +67:12+2 number 291 +68:2+1 namespace 292 +68:5+2 number 293 +68:8+2 operator 294 +68:11+1 number 295 +68:13+2 operator 296 +68:16+3 number 297 +69:2+1 namespace 298 +69:5+2 number 299 +69:8+1 operator 300 +69:10+3 number 301 +69:14+1 operator 302 +69:16+1 number 303 +71:0+10 class 304 +71:11+5 property 305 +71:19+4 property 306 +72:0+10 class 307 +72:11+6 string 308 +72:18+5 property 309 +74:0+10 class 310 +74:11+2 operator 311 +74:13+4 property 312 +75:2+6 namespace 313 +76:2+8 namespace 314 +76:12+14 comment 315 +78:0+10 class 316 +78:11+2 operator 317 +78:13+4 property 318 +80:0+10 class 319 +80:11+2 property 320 +81:0+13 comment 321 +82:0+10 class 322 +82:11+5 property 323 +84:0+1 operator 324 +84:2+7 property 325 +85:2+1 namespace 326 +85:5+1 type 327 +85:6+2 number 328 +87:0+1 operator 329 +87:2+13 property 330 +87:15+10 class 331 +87:25+4 property 332 +87:29+10 class 333 +88:2+18 namespace 334 +88:22+1 type 335 +88:23+3 number 336 +89:2+20 namespace 337 +91:0+1 operator 338 +91:2+7 property 339 +91:11+19 property 340 +91:31+13 comment 341 +92:2+13 namespace 342 +92:17+1 type 343 +92:18+3 number 344 +93:2+15 namespace 345 +95:0+1 operator 346 +95:2+4 string 347 +96:2+1 namespace 348 +96:5+1 type 349 +96:6+2 number 350 +98:0+1 operator 351 +98:2+7 string 352 +99:2+1 operator abstract 353 +99:3+15 namespace 354 +99:18+1 operator abstract 355 +99:22+3 number 356 +101:0+1 operator 357 +101:2+14 string 358 +102:2+12 namespace 359 +102:17+2 number negative 360 +103:2+13 namespace 361 +103:18+1 number 362 +105:0+1 operator 363 +105:2+13 string 364 +106:4+14 namespace 365 +106:20+14 number 366 +108:0+1 operator 367 +108:2+8 string 368 +108:10+16 comment 369 +109:2+19 comment 370 +110:2+14 namespace 371 +110:18+1 type 372 +110:19+5 number 373 +111:2+11 namespace 374 +113:0+10 class 375 +113:11+19 property 376 +114:2+1 namespace 377 +114:5+2 number 378 +114:8+1 operator 379 +114:10+1 number 380 +114:12+3 type 381 +115:2+1 namespace 382 +115:5+2 number 383 +115:8+2 operator 384 +115:11+1 number 385 +115:13+3 type 386 +116:2+1 namespace 387 +116:5+1 type 388 +116:6+5 number negative 389 +116:12+1 operator 390 +116:14+3 number 391 +116:18+3 type
A
internal/lsp/testdata/semantic-unparseable.txtar
··· 1 +-- in.journal -- 2 +garbage 123 @@$ 3 +; note after garbage 4 +# hash note 5 +% percent note 6 +* star note 7 +2024-01-15 ! (CODE) payee 8 + expenses:food $10.50 @ 2 USD 9 + assets:cash !! 10 += expr 11 +~ monthly 12 +account foo 13 +N $ 14 +P 2024-01-15 USD 1.05 15 +garbage $$$$ 123 16 + 17 +-- expect -- 18 +0:0+7 string 19 +0:8+7 string 20 +1:0+20 comment 21 +2:0+11 comment 22 +3:0+14 comment 23 +4:0+11 comment 24 +5:0+10 class 25 +5:11+1 operator 26 +5:13+6 string 27 +5:20+5 string 28 +6:2+8 string 29 +6:10+1 string 30 +6:11+4 string 31 +6:17+1 type 32 +6:18+5 number 33 +6:24+1 operator 34 +6:26+1 number 35 +6:28+3 type 36 +7:2+6 string 37 +7:8+1 string 38 +7:9+4 string 39 +7:15+2 string 40 +8:0+1 operator 41 +8:2+4 string 42 +9:0+1 string 43 +9:2+7 string 44 +10:0+7 keyword 45 +10:8+3 string 46 +11:0+1 keyword 47 +11:2+1 type 48 +12:0+1 keyword 49 +12:2+10 class 50 +12:13+3 type 51 +12:17+4 number 52 +13:0+7 string 53 +13:8+8 string
A
internal/lsp/textdocument_semantic.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "slices" 6 + 7 + "go.lsp.dev/protocol" 8 + "go.lsp.dev/uri" 9 + 10 + "olexsmir.xyz/clerk/internal/lsp/lsputil" 11 + "olexsmir.xyz/clerk/journal/ast" 12 + "olexsmir.xyz/clerk/journal/lexer" 13 + "olexsmir.xyz/clerk/journal/token" 14 +) 15 + 16 +func (s *server) SemanticTokensFull(ctx context.Context, params *protocol.SemanticTokensParams) (*protocol.SemanticTokens, error) { 17 + tokens, ok := s.tokensForDoc(params.TextDocument.URI) 18 + if !ok { 19 + return &protocol.SemanticTokens{}, nil 20 + } 21 + return &protocol.SemanticTokens{Data: encodeSemTokens(tokens)}, nil 22 +} 23 + 24 +func (s *server) SemanticTokensRange(ctx context.Context, params *protocol.SemanticTokensRangeParams) (*protocol.SemanticTokens, error) { 25 + tokens, ok := s.tokensForDoc(params.TextDocument.URI) 26 + if !ok { 27 + return &protocol.SemanticTokens{}, nil 28 + } 29 + start := int(params.Range.Start.Line) 30 + end := int(params.Range.End.Line) 31 + var filtered []semanticToken 32 + for _, t := range tokens { 33 + if t.line >= uint32(start) && t.line <= uint32(end) { 34 + filtered = append(filtered, t) 35 + } 36 + } 37 + return &protocol.SemanticTokens{Data: encodeSemTokens(filtered)}, nil 38 +} 39 + 40 +func (s *server) tokensForDoc(doc uri.URI) ([]semanticToken, bool) { 41 + state, ok := s.getDocState(doc) 42 + if !ok { 43 + return nil, false 44 + } 45 + return tokenizeForSemantics(state.text, state.journal), true 46 +} 47 + 48 +// Implementation 49 + 50 +const ( 51 + SemanticDirective = iota 52 + SemanticDate 53 + SemanticAccount 54 + SemanticCommodity 55 + SemanticAmount 56 + SemanticStatus 57 + SemanticComment 58 + SemString 59 + SemOperator 60 + SemProperty 61 + 62 + semTypeCount // used to ensure we dont go out of bound 63 +) 64 + 65 +var tokenTypeStrings = []string{ 66 + string(protocol.SemanticTokenTypesKeyword), // directive 67 + string(protocol.SemanticTokenTypesClass), // date 68 + string(protocol.SemanticTokenTypesNamespace), // account 69 + string(protocol.SemanticTokenTypesType), // commodity 70 + string(protocol.SemanticTokenTypesNumber), // amount 71 + string(protocol.SemanticTokenTypesOperator), // status 72 + string(protocol.SemanticTokenTypesComment), // comment 73 + string(protocol.SemanticTokenTypesString), // string 74 + string(protocol.SemanticTokenTypesOperator), // operator 75 + string(protocol.SemanticTokenTypesProperty), // property 76 +} 77 + 78 +const ( 79 + modifierAbstract = 1 << 0 // virtual account 80 + modifierNegative = 1 << 1 // negative amount 81 +) 82 + 83 +var modifierStrings = []string{ 84 + "abstract", // bit 0 85 + "negative", // bit 1 86 +} 87 + 88 +func getSemanticTokensLegend() protocol.SemanticTokensLegend { 89 + return protocol.SemanticTokensLegend{ 90 + TokenTypes: tokenTypeStrings, 91 + TokenModifiers: modifierStrings, 92 + } 93 +} 94 + 95 +type semanticToken struct { 96 + line uint32 // 0-based 97 + col uint32 // 0-based UTF-16 98 + length uint32 // UTF-16 code units 99 + tokenType uint32 100 + modifiers uint32 101 +} 102 + 103 +func tokenizeForSemantics(content string, j *ast.Journal) []semanticToken { 104 + if j == nil || len(j.Errors) > 0 { 105 + return semLexerFallback(content) 106 + } 107 + return visitEntries(content, j) 108 +} 109 + 110 +// visitEntries walks the AST and emits semantic tokens. 111 +func visitEntries(content string, j *ast.Journal) []semanticToken { 112 + var out []semanticToken 113 + emit := func(s token.Span, tokType uint32, mods uint32) { 114 + if s.Start.Offset >= s.End.Offset { 115 + return 116 + } 117 + out = append(out, semanticTokenAt(content, s, tokType, mods)) 118 + } 119 + for _, e := range j.Entries { 120 + visitEntry(content, e, emit) 121 + } 122 + return out 123 +} 124 + 125 +// semanticTokenAt converts a source span into a semantic token. 126 +func semanticTokenAt(content string, s token.Span, tokType, mods uint32) semanticToken { 127 + line, col := lsputil.LineCol(content, s.Start.Offset) 128 + length := lsputil.Utf16Len(content, s.Start.Offset, s.End.Offset) 129 + return semanticToken{ 130 + line: uint32(line), 131 + col: uint32(col), 132 + length: uint32(length), 133 + tokenType: tokType, 134 + modifiers: mods, 135 + } 136 +} 137 + 138 +func visitEntry(content string, e ast.Entry, emit semEmitFn) { 139 + switch e := e.(type) { 140 + case *ast.Transaction: 141 + visitTransaction(content, e, emit) 142 + case *ast.PeriodicTransaction: 143 + visitPeriodicTransaction(content, e, emit) 144 + case *ast.AutomatedTransaction: 145 + visitAutomatedTransaction(content, e, emit) 146 + case *ast.AccountDirective: 147 + emit(directiveKeyword(e.Span, "account"), SemanticDirective, 0) 148 + emit(e.Account.Span, SemanticAccount, 0) 149 + if e.Comment != nil { 150 + emit(e.Comment.Span, SemanticComment, 0) 151 + } 152 + case *ast.CommodityDirective: 153 + emit(directiveKeyword(e.Span, "commodity"), SemanticDirective, 0) 154 + if e.Format.Span.End.Offset > 0 { 155 + semEmitAmount(content, &e.Format, emit) 156 + } else if e.CommoditySpan.Start.Offset > 0 && e.CommoditySpan.End.Offset > 0 { 157 + emit(e.CommoditySpan, SemanticCommodity, 0) 158 + } 159 + if e.Comment != nil { 160 + emit(e.Comment.Span, SemanticComment, 0) 161 + } 162 + case *ast.IncludeDirective: 163 + emitDirective(content, e.Span, len("include"), SemString, e.Comment, emit) 164 + case *ast.PayeeDirective: 165 + emitDirective(content, e.Span, len("payee"), SemProperty, e.Comment, emit) 166 + case *ast.TagDirective: 167 + emitDirective(content, e.Span, len("tag"), SemProperty, e.Comment, emit) 168 + case *ast.AliasDirective: 169 + emit(directiveKeyword(e.Span, "alias"), SemanticDirective, 0) 170 + emit(e.From.Span, SemanticAccount, 0) 171 + if op, ok := betweenSpan(content, e.Span.Start.File, e.From.Span.End.Offset, e.To.Span.Start.Offset); ok { 172 + emit(op, SemOperator, 0) 173 + } 174 + emit(e.To.Span, SemanticAccount, 0) 175 + if e.Comment != nil { 176 + emit(e.Comment.Span, SemanticComment, 0) 177 + } 178 + case *ast.YearDirective: 179 + kwLen := len("year") 180 + if content[e.Span.Start.Offset] == 'Y' { 181 + kwLen = 1 182 + } 183 + emitDirective(content, e.Span, kwLen, SemProperty, e.Comment, emit) 184 + case *ast.DecimalMarkDirective: 185 + emitDirective(content, e.Span, len("decimal-mark"), SemProperty, e.Comment, emit) 186 + case *ast.DefaultCommodityDirective: 187 + emit(directiveKeyword(e.Span, "D"), SemanticDirective, 0) 188 + semEmitAmount(content, &e.Amount, emit) 189 + if e.Comment != nil { 190 + emit(e.Comment.Span, SemanticComment, 0) 191 + } 192 + case *ast.MarketPriceDirective: 193 + emit(directiveKeyword(e.Span, "P"), SemanticDirective, 0) 194 + emit(e.DateTime.Date.Span, SemanticDate, 0) 195 + if e.DateTime.Time != nil { 196 + emit(e.DateTime.Time.Span, SemanticDate, 0) 197 + } 198 + // commodity: text between the date (or time) and the amount 199 + commStart := e.DateTime.Date.Span.End.Offset 200 + if e.DateTime.Time != nil { 201 + commStart = e.DateTime.Time.Span.End.Offset 202 + } 203 + if comm, ok := betweenSpan(content, e.Span.Start.File, commStart, e.Amount.Span.Start.Offset); ok { 204 + emit(comm, SemanticCommodity, 0) 205 + } 206 + semEmitAmount(content, &e.Amount, emit) 207 + if e.Comment != nil { 208 + emit(e.Comment.Span, SemanticComment, 0) 209 + } 210 + case *ast.ConversionDirective: 211 + emit(directiveKeyword(e.Span, "C"), SemanticDirective, 0) 212 + semEmitAmount(content, &e.From, emit) 213 + // = operator: text between the two amounts 214 + if op, ok := betweenSpan(content, e.Span.Start.File, e.From.Span.End.Offset, e.To.Span.Start.Offset); ok { 215 + emit(op, SemOperator, 0) 216 + } 217 + semEmitAmount(content, &e.To, emit) 218 + if e.Comment != nil { 219 + emit(e.Comment.Span, SemanticComment, 0) 220 + } 221 + case *ast.Comment: 222 + emit(e.Span, SemanticComment, 0) 223 + case *ast.CommentBlockDirective: 224 + emit(e.Span, SemanticComment, 0) 225 + case *ast.IgnoredDirective: 226 + emitDirective(content, e.Span, len("N"), SemProperty, e.Comment, emit) 227 + case *ast.ApplyDirective: 228 + emitDirective(content, e.Span, len("apply"), SemProperty, e.Comment, emit) 229 + case *ast.EndDirective: 230 + emitDirective(content, e.Span, len("end"), SemProperty, e.Comment, emit) 231 + case *ast.BlankLine: 232 + } 233 +} 234 + 235 +func visitTransaction(content string, t *ast.Transaction, emit semEmitFn) { 236 + emit(t.Date.Span, SemanticDate, 0) 237 + if t.SecondDate != nil { 238 + emit(t.SecondDate.Span, SemanticDate, 0) 239 + } 240 + if t.Status.Value != ast.StatusNone { 241 + emit(t.Status.Span, SemanticStatus, 0) 242 + } 243 + if t.Code != nil { 244 + emit(t.Code.Span, SemString, 0) 245 + } 246 + if t.Payee != nil { 247 + emit(t.Payee.Span, SemProperty, 0) 248 + } 249 + if t.Note != nil { 250 + emit(t.Note.Span, SemProperty, 0) 251 + } 252 + if t.Comment != nil { 253 + emit(t.Comment.Span, SemanticComment, 0) 254 + } 255 + for i := range t.HeaderComments { 256 + emit(t.HeaderComments[i].Span, SemanticComment, 0) 257 + } 258 + for _, p := range t.Postings { 259 + visitPosting(content, p, emit) 260 + } 261 +} 262 + 263 +func visitPeriodicTransaction(content string, pt *ast.PeriodicTransaction, emit semEmitFn) { 264 + // ~ operator is at the start of the period span 265 + emit(offsetSpan(pt.Span.Start.File, pt.Span.Start.Offset, pt.Span.Start.Offset+1), SemOperator, 0) 266 + 267 + // The period span covers the whole expr, including any "from ... to ..." dates 268 + if pt.Period.Span.End.Offset > pt.Period.Span.Start.Offset { 269 + var dates []*ast.Date 270 + if pt.Period.From != nil { 271 + dates = append(dates, pt.Period.From) 272 + } 273 + if pt.Period.To != nil { 274 + dates = append(dates, pt.Period.To) 275 + } 276 + pos := pt.Period.Span.Start.Offset 277 + for _, d := range dates { 278 + if d.Span.Start.Offset > pos { 279 + emit(offsetSpan(pt.Period.Span.Start.File, pos, d.Span.Start.Offset), SemProperty, 0) 280 + } 281 + emit(d.Span, SemanticDate, 0) 282 + pos = d.Span.End.Offset 283 + } 284 + if pos < pt.Period.Span.End.Offset { 285 + emit(offsetSpan(pt.Period.Span.Start.File, pos, pt.Period.Span.End.Offset), SemProperty, 0) 286 + } 287 + } 288 + if pt.Description != nil { 289 + emit(pt.Description.Span, SemProperty, 0) 290 + } 291 + if pt.Comment != nil { 292 + emit(pt.Comment.Span, SemanticComment, 0) 293 + } 294 + for i := range pt.HeaderComments { 295 + emit(pt.HeaderComments[i].Span, SemanticComment, 0) 296 + } 297 + for _, p := range pt.Postings { 298 + visitPosting(content, p, emit) 299 + } 300 +} 301 + 302 +func visitAutomatedTransaction(content string, at *ast.AutomatedTransaction, emit semEmitFn) { 303 + // = operator is at the start of the expression span 304 + emit(offsetSpan(at.Span.Start.File, at.Span.Start.Offset, at.Span.Start.Offset+1), SemOperator, 0) 305 + 306 + if at.Expr.Value != "" { 307 + emit(at.Expr.Span, SemString, 0) 308 + } 309 + if at.Comment != nil { 310 + emit(at.Comment.Span, SemanticComment, 0) 311 + } 312 + for i := range at.HeaderComments { 313 + emit(at.HeaderComments[i].Span, SemanticComment, 0) 314 + } 315 + for _, p := range at.Postings { 316 + visitPosting(content, p, emit) 317 + } 318 +} 319 + 320 +func visitPosting(content string, p *ast.Posting, emit semEmitFn) { 321 + if p.Status.Value != ast.StatusNone { 322 + emit(p.Status.Span, SemanticStatus, 0) 323 + } 324 + 325 + // virtual brackets 326 + if p.Type == ast.PostingVirtualUnbalanced || p.Type == ast.PostingVirtualBalanced { 327 + // opening bracket 328 + for off := p.Span.Start.Offset; off < p.Account.Span.Start.Offset && off < p.Span.End.Offset; off++ { 329 + if content[off] == '(' || content[off] == '[' { 330 + brSpan := token.Span{Start: offsetPos(p.Span.Start.File, off), End: offsetPos(p.Span.Start.File, off+1)} 331 + emit(brSpan, SemOperator, modifierAbstract) 332 + break 333 + } 334 + } 335 + // closing bracket 336 + for off := p.Account.Span.End.Offset; off < p.Span.End.Offset; off++ { 337 + if content[off] == ')' || content[off] == ']' { 338 + brSpan := token.Span{Start: offsetPos(p.Span.Start.File, off), End: offsetPos(p.Span.Start.File, off+1)} 339 + emit(brSpan, SemOperator, modifierAbstract) 340 + break 341 + } 342 + } 343 + } 344 + 345 + emit(p.Account.Span, SemanticAccount, 0) 346 + 347 + if p.Amount != nil { 348 + semEmitAmount(content, p.Amount, emit) 349 + } 350 + if p.Cost != nil { 351 + semEmitCost(content, p.Cost, emit) 352 + } 353 + if p.Balance != nil { 354 + semEmitBalanceAssertion(content, p.Balance, emit) 355 + } 356 + if p.Comment != nil { 357 + emit(p.Comment.Span, SemanticComment, 0) 358 + } 359 + for i := range p.Comments { 360 + emit(p.Comments[i].Span, SemanticComment, 0) 361 + } 362 +} 363 + 364 +// directiveKeyword returns the span of the leading keyword on a directive line. 365 +func directiveKeyword(e token.Span, kw string) token.Span { 366 + return token.Span{Start: e.Start, End: offsetPos(e.Start.File, e.Start.Offset+len(kw))} 367 +} 368 + 369 +// directiveValue returns the trimmed span of the text after the keyword end 370 +// offset, up to the inline comment or the end of the line. 371 +func directiveValue(content string, e token.Span, comment *ast.Comment, kwEnd int) (token.Span, bool) { 372 + end := e.End.Offset 373 + if comment != nil { 374 + end = comment.Span.Start.Offset 375 + } 376 + return betweenSpan(content, e.Start.File, kwEnd, end) 377 +} 378 + 379 +func semEmitAmount(content string, a *ast.Amount, emit semEmitFn) { 380 + if a == nil { 381 + return 382 + } 383 + hasCommodity := a.CommoditySpan.Start.Offset > 0 && a.CommoditySpan.End.Offset > 0 384 + if hasCommodity && a.CommodityPos == ast.CommodityBefore { 385 + emit(a.CommoditySpan, SemanticCommodity, 0) 386 + semEmitQuantity(content, a, emit) 387 + return 388 + } 389 + semEmitQuantity(content, a, emit) 390 + if hasCommodity { 391 + emit(a.CommoditySpan, SemanticCommodity, 0) 392 + } 393 +} 394 + 395 +func semEmitQuantity(content string, a *ast.Amount, emit semEmitFn) { 396 + qStart, qEnd := quantitySpan(content, a) 397 + if qEnd <= qStart { 398 + return 399 + } 400 + mods := uint32(0) 401 + if a.IsNegative { 402 + mods |= modifierNegative 403 + } 404 + emit(offsetSpan(a.Span.Start.File, qStart, qEnd), SemanticAmount, mods) 405 +} 406 + 407 +func quantitySpan(content string, a *ast.Amount) (int, int) { 408 + start, end := a.Span.Start.Offset, a.Span.End.Offset 409 + switch { 410 + case a.Commodity == "": 411 + // bare quantity 412 + case a.CommodityPos == ast.CommodityBefore: 413 + // "$50.00" or "$ 50.00": quantity follows the commodity span 414 + start = a.CommoditySpan.End.Offset 415 + default: // CommodityAfter 416 + // "50.00 USD" or "50.00 USD": quantity precedes the commodity 417 + end = a.CommoditySpan.Start.Offset 418 + } 419 + for start < end && (content[start] == ' ' || content[start] == '\t') { 420 + start++ 421 + } 422 + for end > start && (content[end-1] == ' ' || content[end-1] == '\t') { 423 + end-- 424 + } 425 + return start, end 426 +} 427 + 428 +type semEmitFn func(token.Span, uint32, uint32) 429 + 430 +func emitDirective(content string, e token.Span, kwLen int, valType uint32, comment *ast.Comment, emit semEmitFn) { 431 + kwEnd := e.Start.Offset + kwLen 432 + emit(token.Span{Start: e.Start, End: offsetPos(e.Start.File, kwEnd)}, SemanticDirective, 0) 433 + if v, ok := directiveValue(content, e, comment, kwEnd); ok { 434 + emit(v, valType, 0) 435 + } 436 + if comment != nil { 437 + emit(comment.Span, SemanticComment, 0) 438 + } 439 +} 440 + 441 +func semEmitCost(content string, c *ast.Cost, emit semEmitFn) { 442 + if c.IsTotal { 443 + emit(token.Span{Start: c.Span.Start, End: offsetPos(c.Span.Start.File, c.Span.Start.Offset+2)}, SemOperator, 0) 444 + } else { 445 + emit(token.Span{Start: c.Span.Start, End: offsetPos(c.Span.Start.File, c.Span.Start.Offset+1)}, SemOperator, 0) 446 + } 447 + semEmitAmount(content, &c.Amount, emit) 448 +} 449 + 450 +func semEmitBalanceAssertion(content string, ba *ast.BalanceAssertion, emit semEmitFn) { 451 + // The operator is the run of '=', ':', '*' chars from the span start. 452 + // The ':' of ':=' precedes the '=' token, so back up one offset. 453 + opStart := ba.Span.Start.Offset 454 + if ba.IsAssignment && opStart > 0 { 455 + opStart-- 456 + } 457 + opEnd := opStart 458 + for opEnd < ba.Span.End.Offset && (content[opEnd] == '=' || content[opEnd] == ':' || content[opEnd] == '*') { 459 + opEnd++ 460 + } 461 + emit(token.Span{Start: offsetPos(ba.Span.Start.File, opStart), End: offsetPos(ba.Span.Start.File, opEnd)}, SemOperator, 0) 462 + semEmitAmount(content, &ba.Amount, emit) 463 + if ba.Cost != nil { 464 + semEmitCost(content, ba.Cost, emit) 465 + } 466 +} 467 + 468 +// semLexerFallback produces semantic tokens using only the lexer (for unparseable documents). 469 +func semLexerFallback(content string) []semanticToken { 470 + l := lexer.New("", []byte(content)) 471 + var out []semanticToken 472 + 473 + var commentStart, commentEnd int // 0 = not inside a comment line 474 + take := func(span token.Span, tokType uint32, mods uint32) { 475 + out = append(out, semanticTokenAt(content, span, tokType, mods)) 476 + } 477 + for { 478 + tok := l.Next() 479 + if tok.Type == token.EOF { 480 + if commentStart > 0 { 481 + take(token.Span{Start: offsetPos("", commentStart), End: offsetPos("", commentEnd)}, SemanticComment, 0) 482 + } 483 + break 484 + } 485 + if tok.Type == token.NEWLINE { 486 + if commentStart > 0 { 487 + take(token.Span{Start: offsetPos("", commentStart), End: offsetPos("", commentEnd)}, SemanticComment, 0) 488 + commentStart, commentEnd = 0, 0 489 + } 490 + continue 491 + } 492 + if tok.Type == token.WHITESPACE || tok.Type == token.INDENT { 493 + continue 494 + } 495 + tokType := uint32(SemString) 496 + if commentStart > 0 { 497 + tokType = SemanticComment 498 + if tok.Span.End.Offset > commentEnd { 499 + commentEnd = tok.Span.End.Offset 500 + } 501 + continue 502 + } 503 + switch tok.Type { 504 + case token.SEMICOLON, token.HASH, token.PERCENT: 505 + tokType = SemanticComment 506 + commentStart = tok.Span.Start.Offset 507 + commentEnd = tok.Span.End.Offset 508 + continue 509 + case token.STAR: 510 + tokType = SemanticComment // * at col 0 is comment marker 511 + commentStart = tok.Span.Start.Offset 512 + commentEnd = tok.Span.End.Offset 513 + continue 514 + case token.ACCOUNT, token.COMMODITY, token.INCLUDE, token.ALIAS, 515 + token.PAYEE, token.TAG, token.APPLY, token.END, token.COMMENTKW, 516 + token.YEAR, token.DECIMALMARK, token.D, token.P, token.N, token.C: 517 + tokType = SemanticDirective 518 + case token.DATE: 519 + tokType = SemanticDate 520 + case token.INT, token.DECIMAL: 521 + tokType = SemanticAmount 522 + case token.COMMODITYMARK: 523 + tokType = SemanticCommodity 524 + case token.BANG: 525 + tokType = SemanticStatus 526 + case token.AT, token.ATAT, token.EQ, token.EQEQ, token.EQEQEQ, token.EQSTAR: 527 + tokType = SemOperator 528 + } 529 + take(tok.Span, tokType, 0) 530 + } 531 + return out 532 +} 533 + 534 +func encodeSemTokens(tokens []semanticToken) []uint32 { 535 + if len(tokens) == 0 { 536 + return nil 537 + } 538 + slices.SortFunc(tokens, func(a, b semanticToken) int { 539 + if a.line != b.line { 540 + return int(a.line) - int(b.line) 541 + } 542 + return int(a.col) - int(b.col) 543 + }) 544 + data := make([]uint32, 0, len(tokens)*5) 545 + var prevLine, prevCol uint32 546 + for _, t := range tokens { 547 + var deltaLine, deltaCol uint32 548 + if t.line == prevLine { 549 + deltaLine = 0 550 + deltaCol = t.col - prevCol 551 + } else { 552 + deltaLine = t.line - prevLine 553 + deltaCol = t.col 554 + } 555 + data = append(data, deltaLine, deltaCol, t.length, t.tokenType, t.modifiers) 556 + prevLine = t.line 557 + prevCol = t.col 558 + } 559 + return data 560 +} 561 + 562 +// betweenSpan returns the span of the text between two offsets, trimmed of surrounding whitespace. 563 +func betweenSpan(content, file string, start, end int) (token.Span, bool) { 564 + for start < end && (content[start] == ' ' || content[start] == '\t') { 565 + start++ 566 + } 567 + for end > start && (content[end-1] == ' ' || content[end-1] == '\t' || content[end-1] == '\n' || content[end-1] == '\r') { 568 + end-- 569 + } 570 + if end <= start { 571 + return token.Span{}, false 572 + } 573 + return token.Span{Start: offsetPos(file, start), End: offsetPos(file, end)}, true 574 +} 575 + 576 +func offsetPos(file string, offset int) token.Pos { return token.Pos{File: file, Offset: offset} } 577 +func offsetSpan(file string, start, end int) token.Span { 578 + return token.Span{Start: offsetPos(file, start), End: offsetPos(file, end)} 579 +}
A
internal/lsp/textdocument_semantic_test.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "fmt" 6 + "slices" 7 + "strings" 8 + "testing" 9 + 10 + "go.lsp.dev/protocol" 11 + "go.lsp.dev/uri" 12 + 13 + "olexsmir.xyz/clerk/internal/testutil/golden" 14 +) 15 + 16 +func TestSemanticTokens_Legend(t *testing.T) { 17 + if len(tokenTypeStrings) != semTypeCount { 18 + t.Fatalf("tokenTypeStrings has %d entries, want %d (one per TokenType constant)", 19 + len(tokenTypeStrings), semTypeCount) 20 + } 21 + legend := getSemanticTokensLegend() 22 + if !slices.Equal(legend.TokenTypes, tokenTypeStrings) { 23 + t.Errorf("legend.TokenTypes = %v, want %v", legend.TokenTypes, tokenTypeStrings) 24 + } 25 + if !slices.Equal(legend.TokenModifiers, modifierStrings) { 26 + t.Errorf("legend.TokenModifiers = %v, want %v", legend.TokenModifiers, modifierStrings) 27 + } 28 +} 29 + 30 +func TestSemanticTokensEncode(t *testing.T) { 31 + tests := []struct { 32 + name string 33 + tokens []semanticToken 34 + want []uint32 35 + }{ 36 + {"nil", nil, nil}, 37 + {"empty", []semanticToken{}, nil}, 38 + {"single", []semanticToken{ 39 + {line: 0, col: 0, length: 4, tokenType: SemanticDate}, 40 + }, []uint32{0, 0, 4, SemanticDate, 0}}, 41 + {"line 0 col 0", []semanticToken{ 42 + {line: 0, col: 0, length: 1, tokenType: SemanticDirective}, 43 + }, []uint32{0, 0, 1, SemanticDirective, 0}}, 44 + {"multiple", []semanticToken{ 45 + {line: 0, col: 0, length: 10, tokenType: SemanticDate}, 46 + {line: 0, col: 11, length: 5, tokenType: SemString}, 47 + {line: 1, col: 4, length: 10, tokenType: SemanticAccount}, 48 + }, []uint32{ 49 + 0, 0, 10, SemanticDate, 0, 50 + 0, 11, 5, SemString, 0, 51 + 1, 4, 10, SemanticAccount, 0, 52 + }}, 53 + // the wire format requires non-negative deltas; unsorted input must 54 + // be sorted first (commodity at col 36 comes after amount at col 32) 55 + {"unsorted input", []semanticToken{ 56 + {line: 0, col: 0, length: 10, tokenType: SemanticDate}, 57 + {line: 0, col: 36, length: 3, tokenType: SemanticCommodity}, 58 + {line: 0, col: 32, length: 2, tokenType: SemanticAmount}, 59 + {line: 1, col: 4, length: 6, tokenType: SemanticAccount}, 60 + }, []uint32{ 61 + 0, 0, 10, SemanticDate, 0, 62 + 0, 32, 2, SemanticAmount, 0, 63 + 0, 4, 3, SemanticCommodity, 0, 64 + 1, 4, 6, SemanticAccount, 0, 65 + }}, 66 + } 67 + for _, tt := range tests { 68 + t.Run(tt.name, func(t *testing.T) { 69 + if got := encodeSemTokens(tt.tokens); !slices.Equal(got, tt.want) { 70 + t.Errorf("encodeSemTokens() = %v, want %v", got, tt.want) 71 + } 72 + }) 73 + } 74 +} 75 + 76 +func TestSemanticTokens_Server_SimpleTransaction(t *testing.T) { 77 + content := "2024-01-15 test\n expenses:food $50\n assets:cash\n" 78 + 79 + srv := NewServer("test") 80 + srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal") 81 + 82 + result, err := srv.server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{ 83 + TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")}, 84 + }) 85 + if err != nil { 86 + t.Fatal(err) 87 + } 88 + if result == nil { 89 + t.Fatal("result is nil") 90 + } 91 + if len(result.Data) == 0 { 92 + t.Fatal("expected non-empty token data") 93 + } 94 + if len(result.Data)%5 != 0 { 95 + t.Fatalf("token data length %d is not a multiple of 5", len(result.Data)) 96 + } 97 + // first token is the transaction date at line 0, col 0: deltas are 0, 0 98 + if result.Data[0] != 0 || result.Data[1] != 0 { 99 + t.Errorf("first token deltas = %d,%d, want 0,0", result.Data[0], result.Data[1]) 100 + } 101 +} 102 + 103 +func TestSemanticTokens_Server_EmptyDocument(t *testing.T) { 104 + srv := NewServer("test") 105 + srv.server.openDoc(uri.URI("file:///empty.journal"), "", 1, "journal") 106 + 107 + result, err := srv.server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{ 108 + TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///empty.journal")}, 109 + }) 110 + if err != nil { 111 + t.Fatal(err) 112 + } 113 + if len(result.Data) != 0 { 114 + t.Errorf("expected empty data for empty doc, got %d values", len(result.Data)) 115 + } 116 +} 117 + 118 +func TestSemanticTokens_Server_DocumentNotFound(t *testing.T) { 119 + result, err := NewServer("test").server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{ 120 + TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///unknown.journal")}, 121 + }) 122 + if err != nil { 123 + t.Fatal(err) 124 + } 125 + if result == nil { 126 + t.Fatal("result is nil") 127 + } 128 + if result.Data != nil { 129 + t.Errorf("expected nil Data for unknown doc, got %v", result.Data) 130 + } 131 +} 132 + 133 +func TestSemanticTokens_Server_Range(t *testing.T) { 134 + content := "2024-01-15 test\n expenses:food $50\n2024-01-16 other\n expenses:drinks $20\n" 135 + srv := NewServer("test") 136 + srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal") 137 + 138 + params := &protocol.SemanticTokensRangeParams{ 139 + TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")}, 140 + Range: protocol.Range{ 141 + Start: protocol.Position{Line: 0, Character: 0}, 142 + End: protocol.Position{Line: 1, Character: 50}, 143 + }, 144 + } 145 + result, err := srv.server.SemanticTokensRange(context.Background(), params) 146 + if err != nil { 147 + t.Fatal(err) 148 + } 149 + if result == nil || len(result.Data) == 0 { 150 + t.Fatal("expected non-empty tokens for range") 151 + } 152 + 153 + // decode and assert every token is inside the requested line range 154 + line, col := 0, 0 155 + for i := 0; i+4 < len(result.Data); i += 5 { 156 + dl, dc := result.Data[i], result.Data[i+1] 157 + if dl > 0 { 158 + col = 0 159 + } 160 + line += int(dl) 161 + col += int(dc) 162 + if line > 1 { 163 + t.Fatalf("token at line %d outside requested range [0,1]", line) 164 + } 165 + } 166 +} 167 + 168 +// Golden 169 + 170 +func TestSemanticTokensTxtar(t *testing.T) { 171 + tests := []string{ 172 + "semantic-empty", 173 + "semantic-journal", 174 + "semantic-directives", 175 + "semantic-unparseable", 176 + } 177 + 178 + for _, tt := range tests { 179 + ar := golden.Read(t, tt) 180 + 181 + t.Run(tt+"_golden", func(t *testing.T) { 182 + toks := renderSemanticTokens(tokSem(string(ar.Get("in.journal")))) 183 + golden.Assert(t, ar, toks) 184 + }) 185 + 186 + t.Run(tt+"_no-overlap", func(t *testing.T) { 187 + toks := tokSem(string(ar.Get("in.journal"))) 188 + slices.SortFunc(toks, func(a, b semanticToken) int { 189 + if a.line != b.line { 190 + return int(a.line) - int(b.line) 191 + } 192 + return int(a.col) - int(b.col) 193 + }) 194 + 195 + for i := 1; i < len(toks); i++ { 196 + prev, cur := toks[i-1], toks[i] 197 + if prev.line != cur.line { 198 + continue 199 + } 200 + if cur.col < prev.col+prev.length { 201 + t.Errorf("%s: overlapping tokens on line %d: %s@%d+%d then %s@%d+%d", 202 + tt, prev.line, tokenTypeStrings[prev.tokenType], prev.col, prev.length, 203 + tokenTypeStrings[cur.tokenType], cur.col, cur.length) 204 + } 205 + } 206 + }) 207 + } 208 +} 209 + 210 +func renderSemanticTokens(tokens []semanticToken) string { 211 + slices.SortFunc(tokens, func(a, b semanticToken) int { 212 + if a.line != b.line { 213 + return int(a.line) - int(b.line) 214 + } 215 + return int(a.col) - int(b.col) 216 + }) 217 + var b strings.Builder 218 + for _, tok := range tokens { 219 + fmt.Fprintf(&b, "%d:%d+%d %s", tok.line, tok.col, tok.length, tokenTypeStrings[tok.tokenType]) 220 + for i, m := range modifierStrings { 221 + if tok.modifiers&(1<<uint(i)) != 0 { 222 + b.WriteByte(' ') 223 + b.WriteString(m) 224 + } 225 + } 226 + b.WriteByte('\n') 227 + } 228 + return b.String() 229 +} 230 + 231 +func tokSem(content string) []semanticToken { 232 + return tokenizeForSemantics(content, parseJournalStr(content)) 233 +}
M
internal/lsp/textdocument_sync.go
··· 5 5 6 6 "go.lsp.dev/protocol" 7 7 "go.lsp.dev/uri" 8 + 9 + "olexsmir.xyz/clerk/journal/ast" 10 + "olexsmir.xyz/clerk/journal/lexer" 11 + "olexsmir.xyz/clerk/journal/parser" 8 12 ) 9 13 10 14 func (s *server) DidOpen(ctx context.Context, params *protocol.DidOpenTextDocumentParams) error { ··· 32 36 // Document management 33 37 34 38 func (s *server) openDoc(u uri.URI, text string, version int32, langID protocol.LanguageKind) { 39 + j := parseJournalStr(text) 35 40 s.mu.Lock() 36 41 s.openDocs[u] = docState{ 37 42 text: text, 38 43 version: version, 39 44 languageID: langID, 45 + journal: j, 40 46 } 41 47 s.mu.Unlock() 42 48 } ··· 53 59 switch ev := ch.(type) { 54 60 case *protocol.TextDocumentContentChangeWholeDocument: 55 61 state.text = ev.Text 62 + state.journal = parseJournalStr(ev.Text) 56 63 case *protocol.TextDocumentContentChangePartial: 57 64 _ = ev // TODO: incremental edit support 58 65 } ··· 72 79 s.mu.Unlock() 73 80 return state.text, ok 74 81 } 82 + 83 +func (s *server) getDocState(u uri.URI) (docState, bool) { 84 + s.mu.Lock() 85 + state, ok := s.openDocs[u] 86 + s.mu.Unlock() 87 + return state, ok 88 +} 89 + 90 +// parseJournalStr parses a raw journal string into an AST. 91 +func parseJournalStr(content string) *ast.Journal { 92 + l := lexer.New("", []byte(content)) 93 + p := parser.New(l) 94 + return p.ParseJournal() 95 +}
M
journal/ast/ast.go
··· 77 77 Span token.Span 78 78 } 79 79 80 +type Code struct { 81 + Value string 82 + Span token.Span 83 +} 84 + 85 +type Note struct { 86 + Value string 87 + Span token.Span 88 +} 89 + 90 +type Description struct { 91 + Value string 92 + Span token.Span 93 +} 94 + 95 +type Expr struct { 96 + Value string 97 + Span token.Span 98 +} 99 + 80 100 type Payee struct { 81 101 Name string 82 102 Span token.Span
M
journal/ast/directives.go
··· 11 11 func (AccountDirective) entryNode() {} 12 12 13 13 type CommodityDirective struct { 14 - Commodity string 15 - Format Amount // optional format hint: "1,000.00 UAH" 16 - Comment *Comment // optional inline comment 17 - Span token.Span 14 + 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 18 + Span token.Span 18 19 } 19 20 20 21 func (CommodityDirective) entryNode() {}
M
journal/ast/dump.go
··· 134 134 } 135 135 if t.Code != nil { 136 136 indent(b, depth+1) 137 - fmt.Fprintf(b, "Code: %q\n", *t.Code) 137 + fmt.Fprintf(b, "Code: %q %s\n", t.Code.Value, t.Code.Span) 138 138 } 139 139 if t.Payee != nil { 140 140 indent(b, depth+1) ··· 142 142 } 143 143 if t.Note != nil { 144 144 indent(b, depth+1) 145 - fmt.Fprintf(b, "Note: %q\n", *t.Note) 145 + fmt.Fprintf(b, "Note: %q %s\n", t.Note.Value, t.Note.Span) 146 146 } 147 147 dumpOptComment(b, t.Comment, depth+1) 148 148 if len(t.HeaderComments) > 0 { ··· 161 161 indent(b, depth) 162 162 fmt.Fprintf(b, "AutomatedTransaction %s\n", t.Span) 163 163 indent(b, depth+1) 164 - fmt.Fprintf(b, "Expr: %q\n", t.Expr) 164 + fmt.Fprintf(b, "Expr: %q %s\n", t.Expr.Value, t.Expr.Span) 165 165 dumpOptComment(b, t.Comment, depth+1) 166 166 if len(t.HeaderComments) > 0 { 167 167 indent(b, depth+1) ··· 194 194 } 195 195 if t.Code != nil { 196 196 indent(b, depth+1) 197 - fmt.Fprintf(b, "Code: %q\n", *t.Code) 197 + fmt.Fprintf(b, "Code: %q %s\n", t.Code.Value, t.Code.Span) 198 198 } 199 199 if t.Description != nil { 200 200 indent(b, depth+1) 201 - fmt.Fprintf(b, "Description: %q\n", *t.Description) 201 + fmt.Fprintf(b, "Description: %q %s\n", t.Description.Value, t.Description.Span) 202 202 } 203 203 dumpOptComment(b, t.Comment, depth+1) 204 204 if len(t.HeaderComments) > 0 { ··· 251 251 indent(b, depth+1) 252 252 fmt.Fprintf(b, "Quantity: %s\n", a.Quantity.String()) 253 253 indent(b, depth+1) 254 - fmt.Fprintf(b, "Commodity: %q\n", a.Commodity) 254 + fmt.Fprintf(b, "Commodity: %q", a.Commodity) 255 + if a.Commodity != "" { 256 + fmt.Fprintf(b, " %s", a.CommoditySpan) 257 + } 258 + fmt.Fprintf(b, "\n") 255 259 indent(b, depth+1) 256 260 fmt.Fprintf(b, "CommodityPos: %s\n", a.CommodityPos) 257 261 indent(b, depth+1) ··· 317 321 indent(b, depth) 318 322 fmt.Fprintf(b, "CommodityDirective %s\n", c.Span) 319 323 indent(b, depth+1) 320 - fmt.Fprintf(b, "Commodity: %q\n", c.Commodity) 324 + fmt.Fprintf(b, "Commodity: %q %s\n", c.Commodity, c.CommoditySpan) 321 325 if c.Format.QuantityFmt.Decimal != 0 { 322 326 dumpAmount(b, &c.Format, depth+1) 323 327 }
M
journal/ast/entries.go
··· 13 13 Date Date 14 14 SecondDate *Date // optional =2026-05-18 date 15 15 Status Status // optional */! status 16 - Code *string // optional (123) code 16 + Code *Code // optional (123) code 17 17 Payee *Payee // optional payee 18 - Note *string // part after | 18 + Note *Note // part after | 19 19 Comment *Comment // inline ; on header line 20 20 HeaderComments []*Comment // indented ; lines before first posting 21 21 Postings []*Posting ··· 34 34 func (Period) entryNode() {} 35 35 36 36 type PeriodicTransaction struct { 37 - Period Period // period-expr 38 - Status Status // optional */! status 39 - Code *string // optional (123) code 40 - Description *string // optional description 41 - Comment *Comment // optional inline comment 37 + Period Period // period-expr 38 + Status Status // optional */! status 39 + Code *Code // optional (123) code 40 + Description *Description // optional description 41 + Comment *Comment // optional inline comment 42 42 HeaderComments []*Comment 43 43 Postings []*Posting 44 44 Span token.Span ··· 47 47 func (PeriodicTransaction) entryNode() {} 48 48 49 49 type AutomatedTransaction struct { 50 - Expr string 50 + Expr Expr 51 51 Postings []*Posting 52 52 Comment *Comment // inline ; on header line 53 53 HeaderComments []*Comment // indented ; lines before first posting ··· 90 90 } 91 91 92 92 type Amount struct { 93 - IsNegative bool 94 - Quantity decimal.Decimal 95 - QuantityFmt QuantityFormat 96 - Commodity string 97 - CommodityPos CommodityPos // Before | After 98 - HasSpace bool // "$10" vs "$ 10" 99 - IsExpr bool // e.g: *-1 100 - Expr string // expression text e.g. "amount * -1". set only if IsExpr is true 101 - Span token.Span 93 + IsNegative bool 94 + Quantity decimal.Decimal 95 + QuantityFmt QuantityFormat 96 + Commodity string 97 + CommodityPos CommodityPos // Before | After 98 + CommoditySpan token.Span // span of the commodity within the amount 99 + HasSpace bool // "$10" vs "$ 10" 100 + IsExpr bool // e.g: *-1 101 + Expr string // expression text e.g. "amount * -1". set only if IsExpr is true 102 + Span token.Span 102 103 } 103 104 104 105 type Cost struct {
M
journal/parser/parser.go
··· 140 140 141 141 // optional code 142 142 if p.got(token.LPAREN) { 143 + cs := p.cur.Span 143 144 p.advance() 144 145 var code strings.Builder 145 146 for p.cur.Type != token.RPAREN { 146 147 _, _ = code.WriteString(p.cur.Literal) 147 148 p.advance() 148 149 } 149 - tx.Code = new(code.String()) 150 + rp := p.cur.Span 150 151 p.advance() 152 + tx.Code = &ast.Code{ 153 + Value: code.String(), 154 + Span: token.Span{Start: cs.Start, End: rp.End}, 155 + } 151 156 p.skipWhitespace() 152 157 } 153 158 ··· 163 168 if p.got(token.PIPE) { 164 169 p.advance() 165 170 if p.got(token.TEXT) { 171 + sn := p.cur.Span 166 172 n := p.cur.Literal 167 173 p.advance() 168 - tx.Note = &n 174 + tx.Note = &ast.Note{Value: n, Span: p.span(sn)} 169 175 } 170 176 } 171 177 } ··· 225 231 226 232 pt.Period = p.parsePeriod() 227 233 228 - if desc := p.parseOptPeriodicDescription(); desc != "" { 229 - pt.Description = &desc 234 + if desc, dspan := p.parseOptPeriodicDescription(); desc != "" { 235 + pt.Description = &ast.Description{Value: desc, Span: dspan} 230 236 } 231 237 232 238 comment := p.parseOptInlineComment() ··· 257 263 258 264 at := &ast.AutomatedTransaction{} 259 265 260 - at.Expr = p.parseDirectiveExpr() 266 + // expression 267 + sd := p.cur.Span 268 + expr := p.parseDirectiveExpr() 269 + at.Expr = ast.Expr{Value: expr, Span: p.span(sd)} 261 270 at.Comment = p.parseOptInlineComment() 262 271 p.expectNewline() 263 272 ··· 312 321 dateStr = after[:end] 313 322 } 314 323 if d := parseSimpleDate(dateStr); d.Year > 0 { 324 + fromOff := strings.Index(str, dateStr) 325 + d.Span = periodDateSpan(period, str, dateStr, fromOff) 315 326 period.From = &d 316 327 rest := after 317 328 if end >= 0 { ··· 322 333 toAfter = toAfter[:toEnd] 323 334 } 324 335 if d := parseSimpleDate(toAfter); d.Year > 0 { 336 + d.Span = periodDateSpan(period, str, toAfter, fromOff+len(dateStr)) 325 337 period.To = &d 326 338 } 327 339 } ··· 330 342 return period 331 343 } 332 344 345 +// periodDateSpan returns the source span of dateStr, which occurs in the 346 +// period text at or after searchFrom. The period span and text cover the same 347 +// bytes, so offsets line up 1:1. 348 +func periodDateSpan(period ast.Period, text, dateStr string, searchFrom int) token.Span { 349 + off := strings.Index(text[searchFrom:], dateStr) 350 + abs := period.Span.Start.Offset + searchFrom + off 351 + return token.Span{ 352 + Start: token.Pos{File: period.Span.Start.File, Offset: abs}, 353 + End: token.Pos{File: period.Span.Start.File, Offset: abs + len(dateStr)}, 354 + } 355 +} 356 + 333 357 func (p *Parser) parseComment() *ast.Comment { 334 358 s := p.cur.Span 335 359 marker := p.cur.Literal[0] ··· 381 405 p.skipWhitespace() 382 406 383 407 var commodity string 408 + var commoditySpan token.Span 384 409 var format *ast.Amount 385 410 386 411 switch p.cur.Type { 387 412 case token.COMMODITYMARK, token.TEXT, token.STRING: 413 + cs := p.cur.Span 388 414 commodity = p.cur.Literal 389 415 if p.got(token.STRING) { 390 416 commodity = unquote(commodity) 391 417 } 392 418 p.advance() 419 + commoditySpan = token.Span{Start: cs.Start, End: p.cur.Span.Start} 393 420 hadSpace := p.got(token.WHITESPACE) 394 421 p.skipWhitespace() 395 422 if p.got(token.INT) || p.got(token.DECIMAL) || p.got(token.TEXT) { 396 423 format = p.parseAmount() 397 424 format.Commodity = commodity 425 + format.CommoditySpan = commoditySpan 398 426 format.CommodityPos = ast.CommodityBefore 399 427 format.HasSpace = hadSpace 400 428 } 401 429 case token.INT, token.DECIMAL: 402 430 format = p.parseAmount() 403 431 commodity = format.Commodity 432 + commoditySpan = format.CommoditySpan 404 433 default: 405 434 p.errorf("expected commodity name or amount, got %s", p.cur.Type) 406 435 } ··· 428 457 } 429 458 430 459 cd := &ast.CommodityDirective{ 431 - Commodity: commodity, 432 - Comment: comment, 433 - Span: p.span(s), 460 + Commodity: commodity, 461 + CommoditySpan: commoditySpan, 462 + Comment: comment, 463 + Span: p.span(s), 434 464 } 435 465 if format != nil { 436 466 cd.Format = *format ··· 798 828 s := p.cur.Span 799 829 amt := &ast.Amount{ 800 830 QuantityFmt: ast.QuantityFormat{Decimal: '.'}, 801 - Span: p.span(s), 802 831 } 832 + defer func() { 833 + // The span covers from the first token to the start of the next unconsumed token. 834 + // Since parseQuantityInto (and possible commodity consumption) advanced past the last 835 + // amount token, p.cur points to the next token after the amount — which is the correct end. 836 + amt.Span = p.span(s) 837 + }() 803 838 804 839 // commodity before quantity: $10.00, eur 10.00 805 840 if p.got(token.COMMODITYMARK) || p.got(token.TEXT) || p.got(token.STRING) { 841 + cs := p.cur.Span 806 842 amt.Commodity = unquote(p.cur.Literal) 807 843 amt.CommodityPos = ast.CommodityBefore 808 844 p.advance() 845 + amt.CommoditySpan = token.Span{Start: cs.Start, End: p.cur.Span.Start} 809 846 if p.got(token.WHITESPACE) { 810 847 amt.HasSpace = true 811 848 p.skipWhitespace() ··· 830 867 831 868 // commodity before quantity: -$120, -eur 120: 832 869 if p.got(token.COMMODITYMARK) || p.got(token.TEXT) || p.got(token.STRING) { 870 + cs := p.cur.Span 833 871 amt.Commodity = unquote(p.cur.Literal) 834 872 amt.CommodityPos = ast.CommodityBefore 835 873 p.advance() 874 + amt.CommoditySpan = token.Span{Start: cs.Start, End: p.cur.Span.Start} 836 875 if p.got(token.WHITESPACE) { 837 876 amt.HasSpace = true 838 877 p.skipWhitespace() ··· 847 886 case token.WHITESPACE: 848 887 p.skipWhitespace() 849 888 if p.got(token.COMMODITYMARK) || p.got(token.TEXT) || p.got(token.STRING) { 889 + cs := p.cur.Span 850 890 amt.HasSpace = true 851 891 amt.Commodity = unquote(p.cur.Literal) 852 892 amt.CommodityPos = ast.CommodityAfter 853 893 p.advance() 894 + amt.CommoditySpan = token.Span{Start: cs.Start, End: p.cur.Span.Start} 854 895 } 855 896 case token.COMMODITYMARK, token.TEXT, token.STRING: 897 + cs := p.cur.Span 856 898 amt.Commodity = unquote(p.cur.Literal) 857 899 amt.CommodityPos = ast.CommodityAfter 858 900 p.advance() 901 + amt.CommoditySpan = token.Span{Start: cs.Start, End: p.cur.Span.Start} 859 902 } 860 903 } 861 904 } ··· 1168 1211 } 1169 1212 } 1170 1213 1171 -func (p *Parser) parseOptPeriodicDescription() string { 1214 +func (p *Parser) parseOptPeriodicDescription() (string, token.Span) { 1172 1215 if p.cur.Type != token.WHITESPACE || len(p.cur.Literal) < 2 { 1173 - return "" 1216 + return "", token.Span{} 1174 1217 } 1175 1218 1176 1219 p.skipWhitespace() 1177 1220 1178 1221 if p.cur.Type != token.TEXT { 1179 - return "" 1222 + return "", token.Span{} 1180 1223 } 1181 1224 1182 - return p.parseDescription() 1225 + s := p.cur.Span 1226 + desc := p.parseDescription() 1227 + return desc, p.span(s) 1183 1228 } 1184 1229 1185 1230 func (p *Parser) parseDescription() string {
M
journal/parser/testdata/C_directive.txtar
··· 4 4 Journal 5 5 ConversionDirective j:1:1-2:1 6 6 From: 7 - Amount j:1:3-1:3 7 + Amount j:1:3-1:8 8 8 Quantity: 1 9 - Commodity: "s" 9 + Commodity: "s" j:1:7-1:8 10 10 CommodityPos: After 11 11 HasSpace: false 12 12 Precision: 2 13 13 Decimal: "." 14 14 To: 15 - Amount j:1:11-1:11 15 + Amount j:1:11-2:0 16 16 Quantity: 100 17 - Commodity: "c" 17 + Commodity: "c" j:1:14-2:0 18 18 CommodityPos: After 19 19 HasSpace: false 20 20 Precision: 0
M
journal/parser/testdata/D_directive.txtar
··· 4 4 -- expect -- 5 5 Journal 6 6 DefaultCommodityDirective j:1:1-2:1 7 - Amount j:1:3-1:3 7 + Amount j:1:3-2:0 8 8 Quantity: 1 9 - Commodity: "$" 9 + Commodity: "$" j:1:3-1:4 10 10 CommodityPos: Before 11 11 HasSpace: false 12 12 Precision: 2 13 13 Decimal: "." 14 14 DefaultCommodityDirective j:2:1-3:1 15 - Amount j:2:3-2:3 15 + Amount j:2:3-3:0 16 16 Quantity: 10 17 - Commodity: "UAH" 17 + Commodity: "UAH" j:2:6-3:0 18 18 CommodityPos: After 19 19 HasSpace: true 20 20 Precision: 0
M
journal/parser/testdata/P_directive.txtar
··· 5 5 MarketPriceDirective j:1:1-2:1 6 6 Date: 2024/01/01 7 7 Commodity: "USD" 8 - Amount j:1:18-1:18 8 + Amount j:1:18-2:0 9 9 Quantity: 40.5 10 - Commodity: "UAH" 10 + Commodity: "UAH" j:1:24-2:0 11 11 CommodityPos: After 12 12 HasSpace: true 13 13 Precision: 2
M
journal/parser/testdata/P_directive_with_time.txtar
··· 6 6 Date: 2024-01-01 7 7 Time: 12:00:00 8 8 Commodity: "USD" 9 - Amount j:1:27-1:27 9 + Amount j:1:27-2:0 10 10 Quantity: 40.5 11 - Commodity: "UAH" 11 + Commodity: "UAH" j:1:33-2:0 12 12 CommodityPos: After 13 13 HasSpace: true 14 14 Precision: 2
M
journal/parser/testdata/automated_transaction.txtar
··· 11 11 -- expect -- 12 12 Journal 13 13 AutomatedTransaction j:1:1-4:0 14 - Expr: "^income" 14 + Expr: "^income" j:1:3-2:0 15 15 Posting j:2:1-4:0 16 16 Type: unbalanced virtual 17 17 Account j:2:6-2:21 18 18 SubAccount: "liabilities" j:2:6-2:17 19 19 SubAccount: "tax" j:2:18-2:21 20 - Amount j:2:25-2:25 20 + Amount j:2:25-3:0 21 21 Quantity: 0.33 22 22 Commodity: "" 23 23 CommodityPos: Before ··· 27 27 Decimal: "." 28 28 BlankLine j:4:0-4:1 29 29 AutomatedTransaction j:4:1-8:0 30 - Expr: "expenses:gifts" 30 + Expr: "expenses:gifts" j:4:3-5:0 31 31 Posting j:5:1-6:1 32 32 Account j:5:5-5:17 33 33 SubAccount: "budget" j:5:5-5:11 34 34 SubAccount: "gifts" j:5:12-5:17 35 - Amount j:5:20-5:20 35 + Amount j:5:20-6:0 36 36 Quantity: -1 37 37 Commodity: "" 38 38 CommodityPos: Before ··· 44 44 Account j:6:5-6:18 45 45 SubAccount: "assets" j:6:5-6:11 46 46 SubAccount: "budget" j:6:12-6:18 47 - Amount j:6:21-6:21 47 + Amount j:6:21-7:0 48 48 Quantity: 1 49 49 Commodity: "" 50 50 CommodityPos: Before ··· 54 54 Decimal: "." 55 55 BlankLine j:8:0-8:1 56 56 AutomatedTransaction j:8:1-10:1 57 - Expr: "income:salary" 57 + Expr: "income:salary" j:8:3-9:0 58 58 Posting j:9:1-10:1 59 59 Account j:9:5-9:19 60 60 SubAccount: "budget" j:9:5-9:11
M
journal/parser/testdata/bad_between_good.txtar
··· 12 12 Account j:2:5-2:18 13 13 SubAccount: "expenses" j:2:5-2:13 14 14 SubAccount: "food" j:2:14-2:18 15 - Amount j:2:20-2:20 15 + Amount j:2:20-3:0 16 16 Quantity: 10 17 - Commodity: "$" 17 + Commodity: "$" j:2:20-2:21 18 18 CommodityPos: Before 19 19 HasSpace: false 20 20 Precision: 0 ··· 23 23 Account j:4:5-4:16 24 24 SubAccount: "assets" j:4:5-4:11 25 25 SubAccount: "cash" j:4:12-4:16 26 - Amount j:4:18-4:18 26 + Amount j:4:18-5:0 27 27 Quantity: 5 28 - Commodity: "$" 28 + Commodity: "$" j:4:18-4:19 29 29 CommodityPos: Before 30 30 HasSpace: false 31 31 Precision: 0
M
journal/parser/testdata/bad_posting_at_end.txtar
··· 15 15 Account j:2:5-2:18 16 16 SubAccount: "expenses" j:2:5-2:13 17 17 SubAccount: "food" j:2:14-2:18 18 - Amount j:2:20-2:20 18 + Amount j:2:20-3:0 19 19 Quantity: 10 20 - Commodity: "$" 20 + Commodity: "$" j:2:20-2:21 21 21 CommodityPos: Before 22 22 HasSpace: false 23 23 Precision: 0 ··· 30 30 Account j:6:5-6:18 31 31 SubAccount: "income" j:6:5-6:11 32 32 SubAccount: "salary" j:6:12-6:18 33 - Amount j:6:20-6:20 33 + Amount j:6:20-7:0 34 34 Quantity: 1000 35 - Commodity: "$" 35 + Commodity: "$" j:6:20-6:21 36 36 CommodityPos: Before 37 37 HasSpace: false 38 38 Precision: 0
M
journal/parser/testdata/bad_then_next_transaction.txtar
··· 15 15 Account j:2:5-2:18 16 16 SubAccount: "expenses" j:2:5-2:13 17 17 SubAccount: "food" j:2:14-2:18 18 - Amount j:2:20-2:20 18 + Amount j:2:20-3:0 19 19 Quantity: 10 20 - Commodity: "$" 20 + Commodity: "$" j:2:20-2:21 21 21 CommodityPos: Before 22 22 HasSpace: false 23 23 Precision: 0 ··· 30 30 Account j:6:5-6:18 31 31 SubAccount: "income" j:6:5-6:11 32 32 SubAccount: "salary" j:6:12-6:18 33 - Amount j:6:20-6:20 33 + Amount j:6:20-7:0 34 34 Quantity: 1000 35 - Commodity: "$" 35 + Commodity: "$" j:6:20-6:21 36 36 CommodityPos: Before 37 37 HasSpace: false 38 38 Precision: 0
M
journal/parser/testdata/comment_between_bad_postings.txtar
··· 14 14 Account j:2:5-2:18 15 15 SubAccount: "expenses" j:2:5-2:13 16 16 SubAccount: "food" j:2:14-2:18 17 - Amount j:2:20-2:20 17 + Amount j:2:20-3:0 18 18 Quantity: 10 19 - Commodity: "$" 19 + Commodity: "$" j:2:20-2:21 20 20 CommodityPos: Before 21 21 HasSpace: false 22 22 Precision: 0
M
journal/parser/testdata/commodity_quantity_after.txtar
··· 3 3 -- expect -- 4 4 Journal 5 5 CommodityDirective j:1:1-2:1 6 - Commodity: "UAH" 7 - Amount j:1:15-1:15 6 + Commodity: "UAH" j:1:11-1:14 7 + Amount j:1:15-2:0 8 8 Quantity: 1000 9 - Commodity: "UAH" 9 + Commodity: "UAH" j:1:11-1:14 10 10 CommodityPos: Before 11 11 HasSpace: true 12 12 Precision: 2
M
journal/parser/testdata/commodity_quantity_first.txtar
··· 3 3 -- expect -- 4 4 Journal 5 5 CommodityDirective j:1:1-2:1 6 - Commodity: "UAH" 7 - Amount j:1:11-1:11 6 + Commodity: "UAH" j:1:20-2:0 7 + Amount j:1:11-2:0 8 8 Quantity: 1000 9 - Commodity: "UAH" 9 + Commodity: "UAH" j:1:20-2:0 10 10 CommodityPos: After 11 11 HasSpace: true 12 12 Precision: 2
M
journal/parser/testdata/comodity_directive_no_space.txtar
··· 3 3 -- expect -- 4 4 Journal 5 5 CommodityDirective j:1:1-2:1 6 - Commodity: "$" 7 - Amount j:1:12-1:12 6 + Commodity: "$" j:1:11-1:12 7 + Amount j:1:12-2:0 8 8 Quantity: 1000 9 - Commodity: "$" 9 + Commodity: "$" j:1:11-1:12 10 10 CommodityPos: Before 11 11 HasSpace: false 12 12 Precision: 2
M
journal/parser/testdata/digit_group_marks.txtar
··· 12 12 Account j:2:3-2:16 13 13 SubAccount: "expenses" j:2:3-2:11 14 14 SubAccount: "food" j:2:12-2:16 15 - Amount j:2:18-2:18 15 + Amount j:2:18-3:0 16 16 Quantity: 1000 17 - Commodity: "UAH" 17 + Commodity: "UAH" j:2:27-3:0 18 18 CommodityPos: After 19 19 HasSpace: true 20 20 Precision: 2 ··· 24 24 Account j:3:3-3:20 25 25 SubAccount: "expenses" j:3:3-3:11 26 26 SubAccount: "supplies" j:3:12-3:20 27 - Amount j:3:22-3:22 27 + Amount j:3:22-4:0 28 28 Quantity: 1000 29 - Commodity: "USD" 29 + Commodity: "USD" j:3:31-4:0 30 30 CommodityPos: After 31 31 HasSpace: true 32 32 Precision: 2 ··· 36 36 Account j:4:3-4:18 37 37 SubAccount: "assets" j:4:3-4:9 38 38 SubAccount: "checking" j:4:10-4:18 39 - Amount j:4:20-4:20 39 + Amount j:4:20-5:0 40 40 Quantity: -2000 41 - Commodity: "USD" 41 + Commodity: "USD" j:4:30-5:0 42 42 CommodityPos: After 43 43 HasSpace: true 44 44 Precision: 2
M
journal/parser/testdata/illegal_between_transactions.txtar
··· 15 15 Account j:2:5-2:18 16 16 SubAccount: "expenses" j:2:5-2:13 17 17 SubAccount: "food" j:2:14-2:18 18 - Amount j:2:20-2:20 18 + Amount j:2:20-3:0 19 19 Quantity: 10 20 - Commodity: "$" 20 + Commodity: "$" j:2:20-2:21 21 21 CommodityPos: Before 22 22 HasSpace: false 23 23 Precision: 0 ··· 34 34 Account j:6:5-6:18 35 35 SubAccount: "income" j:6:5-6:11 36 36 SubAccount: "salary" j:6:12-6:18 37 - Amount j:6:20-6:20 37 + Amount j:6:20-7:0 38 38 Quantity: 1000 39 - Commodity: "$" 39 + Commodity: "$" j:6:20-6:21 40 40 CommodityPos: Before 41 41 HasSpace: false 42 42 Precision: 0
M
journal/parser/testdata/multiple_bad_postings.txtar
··· 13 13 Account j:2:5-2:18 14 14 SubAccount: "expenses" j:2:5-2:13 15 15 SubAccount: "food" j:2:14-2:18 16 - Amount j:2:20-2:20 16 + Amount j:2:20-3:0 17 17 Quantity: 10 18 - Commodity: "$" 18 + Commodity: "$" j:2:20-2:21 19 19 CommodityPos: Before 20 20 HasSpace: false 21 21 Precision: 0
M
journal/parser/testdata/nested_apply_tag_directives.txtar
··· 19 19 Account j:4:3-4:17 20 20 SubAccount: "expenses" j:4:3-4:11 21 21 SubAccount: "books" j:4:12-4:17 22 - Amount j:4:19-4:19 22 + Amount j:4:19-5:0 23 23 Quantity: 20 24 - Commodity: "$" 24 + Commodity: "$" j:4:19-4:20 25 25 CommodityPos: Before 26 26 HasSpace: false 27 27 Precision: 2
M
journal/parser/testdata/period_transaction_expressions.txtar
··· 29 29 Account j:3:5-3:18 30 30 SubAccount: "expenses" j:3:5-3:13 31 31 SubAccount: "rent" j:3:14-3:18 32 - Amount j:3:28-3:28 32 + Amount j:3:28-4:0 33 33 Quantity: 2000 34 - Commodity: "$" 34 + Commodity: "$" j:3:28-3:29 35 35 CommodityPos: Before 36 36 HasSpace: false 37 37 Precision: 0 ··· 52 52 Account j:7:5-7:23 53 53 SubAccount: "expenses" j:7:5-7:13 54 54 SubAccount: "utilities" j:7:14-7:23 55 - Amount j:7:33-7:33 55 + Amount j:7:33-8:0 56 56 Quantity: 400 57 - Commodity: "$" 57 + Commodity: "$" j:7:33-7:34 58 58 CommodityPos: Before 59 59 HasSpace: false 60 60 Precision: 0 ··· 69 69 PeriodicTransaction j:10:1-14:0 70 70 Period: "every 2 months" 71 71 Status: "*" 72 - Description: "in 2023, we will review" 72 + Description: "in 2023, we will review" j:10:19-11:0 73 73 Posting j:11:1-12:1 74 74 Account j:11:5-11:23 75 75 SubAccount: "expenses" j:11:5-11:13 76 76 SubAccount: "utilities" j:11:14-11:23 77 - Amount j:11:33-11:33 77 + Amount j:11:33-12:0 78 78 Quantity: 400 79 - Commodity: "$" 79 + Commodity: "$" j:11:33-11:34 80 80 CommodityPos: Before 81 81 HasSpace: false 82 82 Precision: 0 ··· 91 91 PeriodicTransaction j:14:1-18:0 92 92 Period: "monthly" 93 93 Status: "*" 94 - Description: "Next year blah blah" 94 + Description: "Next year blah blah" j:14:12-15:0 95 95 Posting j:15:1-16:1 96 96 Account j:15:5-15:18 97 97 SubAccount: "expenses" j:15:5-15:13 98 98 SubAccount: "food" j:15:14-15:18 99 - Amount j:15:20-15:20 99 + Amount j:15:20-16:0 100 100 Quantity: 100 101 - Commodity: "$" 101 + Commodity: "$" j:15:20-15:21 102 102 CommodityPos: Before 103 103 HasSpace: false 104 104 Precision: 0 ··· 119 119 Account j:19:5-19:18 120 120 SubAccount: "expenses" j:19:5-19:13 121 121 SubAccount: "food" j:19:14-19:18 122 - Amount j:19:20-19:20 122 + Amount j:19:20-20:0 123 123 Quantity: 100 124 - Commodity: "$" 124 + Commodity: "$" j:19:20-19:21 125 125 CommodityPos: Before 126 126 HasSpace: false 127 127 Precision: 0
M
journal/parser/testdata/quoted_payee_names.txtar
··· 11 11 Account j:2:3-2:16 12 12 SubAccount: "expenses" j:2:3-2:11 13 13 SubAccount: "food" j:2:12-2:16 14 - Amount j:2:18-2:18 14 + Amount j:2:18-3:0 15 15 Quantity: 10 16 - Commodity: "$" 16 + Commodity: "$" j:2:18-2:19 17 17 CommodityPos: Before 18 18 HasSpace: false 19 19 Precision: 0
M
journal/parser/testdata/recovery_after_bad_posting.txtar
··· 24 24 Account j:6:5-6:18 25 25 SubAccount: "income" j:6:5-6:11 26 26 SubAccount: "salary" j:6:12-6:18 27 - Amount j:6:20-6:20 27 + Amount j:6:20-7:0 28 28 Quantity: 1000 29 - Commodity: "$" 29 + Commodity: "$" j:6:20-6:21 30 30 CommodityPos: Before 31 31 HasSpace: false 32 32 Precision: 0
M
journal/parser/testdata/special_chars_in_description.txtar
··· 22 22 Transaction j:2:1-3:1 23 23 Date: 2024/01/01 24 24 Payee: "groceries" j:2:12-2:21 25 - Note: "1 + 2" 25 + Note: "1 + 2" j:2:24-3:0 26 26 Transaction j:3:1-4:1 27 27 Date: 2024/01/01 28 28 Payee: "groceries" j:3:12-3:21 29 - Note: "1 * 2" 29 + Note: "1 * 2" j:3:24-4:0 30 30 Transaction j:4:1-5:1 31 31 Date: 2024/01/01 32 32 Payee: "groceries" j:4:12-4:21 33 - Note: "1 ! 2" 33 + Note: "1 ! 2" j:4:24-5:0 34 34 Transaction j:5:1-6:1 35 35 Date: 2024/01/01 36 36 Payee: "groceries" j:5:12-5:21 37 - Note: "1 # 2" 37 + Note: "1 # 2" j:5:24-6:0 38 38 Transaction j:6:1-7:1 39 39 Date: 2024/01/01 40 40 Payee: "groceries" j:6:12-6:21 41 - Note: "1 % 2" 41 + Note: "1 % 2" j:6:24-7:0 42 42 Transaction j:7:1-8:1 43 43 Date: 2024/01/01 44 44 Payee: "groceries" j:7:12-7:21 45 - Note: "1 ^ 2" 45 + Note: "1 ^ 2" j:7:24-8:0 46 46 Transaction j:8:1-9:1 47 47 Date: 2024/01/01 48 48 Payee: "groceries" j:8:12-8:21 49 - Note: "1 & 2" 49 + Note: "1 & 2" j:8:24-9:0 50 50 Transaction j:9:1-10:1 51 51 Date: 2024/01/01 52 52 Payee: "groceries" j:9:12-9:21 53 - Note: "1 ( 2" 53 + Note: "1 ( 2" j:9:24-10:0 54 54 Transaction j:10:1-11:1 55 55 Date: 2024/01/01 56 56 Payee: "groceries" j:10:12-10:21 57 - Note: "1 ) 2" 57 + Note: "1 ) 2" j:10:24-11:0 58 58 Transaction j:11:1-12:1 59 59 Date: 2024/01/01 60 60 Payee: "groceries" j:11:12-11:21 61 - Note: "1 [ 2" 61 + Note: "1 [ 2" j:11:24-12:0 62 62 Transaction j:12:1-13:1 63 63 Date: 2024/01/01 64 64 Payee: "groceries" j:12:12-12:21 65 - Note: "1 ] 2" 65 + Note: "1 ] 2" j:12:24-13:0 66 66 Transaction j:13:1-14:1 67 67 Date: 2024/01/01 68 68 Payee: "groceries" j:13:12-13:21 69 - Note: "1 { 2" 69 + Note: "1 { 2" j:13:24-14:0 70 70 Transaction j:14:1-15:1 71 71 Date: 2024/01/01 72 72 Payee: "groceries" j:14:12-14:21 73 - Note: "1 } 2" 73 + Note: "1 } 2" j:14:24-15:0 74 74 Transaction j:15:1-18:1 75 75 Date: 2026-06-07 76 76 State: "*" 77 77 Payee: "payee !one" j:15:14-15:24 78 - Note: "something *important*" 78 + Note: "something *important*" j:15:27-16:0 79 79 Posting j:16:1-17:1 80 80 Account j:16:5-16:18 81 81 SubAccount: "expenses" j:16:5-16:13 82 82 SubAccount: "food" j:16:14-16:18 83 - Amount j:16:20-16:20 83 + Amount j:16:20-17:0 84 84 Quantity: 40 85 85 Commodity: "" 86 86 CommodityPos: Before
M
journal/parser/testdata/three_bad_postings.txtar
··· 14 14 Account j:2:5-2:18 15 15 SubAccount: "expenses" j:2:5-2:13 16 16 SubAccount: "food" j:2:14-2:18 17 - Amount j:2:20-2:20 17 + Amount j:2:20-3:0 18 18 Quantity: 10 19 - Commodity: "$" 19 + Commodity: "$" j:2:20-2:21 20 20 CommodityPos: Before 21 21 HasSpace: false 22 22 Precision: 0
M
journal/parser/testdata/transaction_in_ukrainian.txtar
··· 11 11 Account j:2:3-2:14 12 12 SubAccount: "витрати" j:2:3-2:10 13 13 SubAccount: "їжа" j:2:11-2:14 14 - Amount j:2:16-2:16 14 + Amount j:2:16-3:0 15 15 Quantity: 350 16 - Commodity: "UAH" 16 + Commodity: "UAH" j:2:20-3:0 17 17 CommodityPos: After 18 18 HasSpace: true 19 19 Precision: 0
M
journal/parser/testdata/transaction_with_balance_assertion.txtar
··· 19 19 Account j:2:2-2:15 20 20 SubAccount: "expenses" j:2:2-2:10 21 21 SubAccount: "food" j:2:11-2:15 22 - Amount j:2:17-2:17 22 + Amount j:2:17-2:23 23 23 Quantity: 10 24 - Commodity: "$" 24 + Commodity: "$" j:2:17-2:18 25 25 CommodityPos: Before 26 26 HasSpace: false 27 27 Precision: 2 ··· 29 29 BalanceAssertion j:2:24-3:0 30 30 IsStrict: false 31 31 IsInclusive: false 32 - Amount j:2:26-2:26 32 + Amount j:2:26-3:0 33 33 Quantity: 100 34 - Commodity: "$" 34 + Commodity: "$" j:2:26-2:27 35 35 CommodityPos: Before 36 36 HasSpace: false 37 37 Precision: 2 ··· 49 49 Account j:6:2-6:15 50 50 SubAccount: "expenses" j:6:2-6:10 51 51 SubAccount: "food" j:6:11-6:15 52 - Amount j:6:17-6:17 52 + Amount j:6:17-6:23 53 53 Quantity: 10 54 - Commodity: "$" 54 + Commodity: "$" j:6:17-6:18 55 55 CommodityPos: Before 56 56 HasSpace: false 57 57 Precision: 2 ··· 59 59 BalanceAssertion j:6:24-7:0 60 60 IsStrict: true 61 61 IsInclusive: false 62 - Amount j:6:27-6:27 62 + Amount j:6:27-7:0 63 63 Quantity: 100 64 - Commodity: "$" 64 + Commodity: "$" j:6:27-6:28 65 65 CommodityPos: Before 66 66 HasSpace: false 67 67 Precision: 2 ··· 74 74 BalanceAssertion j:7:19-8:0 75 75 IsStrict: true 76 76 IsInclusive: false 77 - Amount j:7:22-7:22 77 + Amount j:7:22-8:0 78 78 Quantity: 0 79 - Commodity: "$" 79 + Commodity: "$" j:7:22-7:23 80 80 CommodityPos: Before 81 81 HasSpace: false 82 82 Precision: 2 ··· 89 89 Account j:10:2-10:15 90 90 SubAccount: "expenses" j:10:2-10:10 91 91 SubAccount: "food" j:10:11-10:15 92 - Amount j:10:17-10:17 92 + Amount j:10:17-11:0 93 93 Quantity: 10 94 - Commodity: "$" 94 + Commodity: "$" j:10:17-10:18 95 95 CommodityPos: Before 96 96 HasSpace: false 97 97 Precision: 2 ··· 104 104 BalanceAssertion j:11:19-12:0 105 105 IsStrict: false 106 106 IsInclusive: false 107 - Amount j:11:21-11:21 107 + Amount j:11:21-12:0 108 108 Quantity: 0 109 - Commodity: "$" 109 + Commodity: "$" j:11:21-11:22 110 110 CommodityPos: Before 111 111 HasSpace: false 112 112 Precision: 2
M
journal/parser/testdata/transaction_with_code.txtar
··· 11 11 Account j:2:5-2:18 12 12 SubAccount: "expenses" j:2:5-2:13 13 13 SubAccount: "food" j:2:14-2:18 14 - Amount j:2:20-2:20 14 + Amount j:2:20-3:0 15 15 Quantity: 10 16 - Commodity: "$" 16 + Commodity: "$" j:2:20-2:21 17 17 CommodityPos: Before 18 18 HasSpace: false 19 19 Precision: 2
M
journal/parser/testdata/transaction_with_cost_and_assertion.txtar
··· 11 11 Account j:2:2-2:14 12 12 SubAccount: "expenses" j:2:2-2:10 13 13 SubAccount: "atm" j:2:11-2:14 14 - Amount j:2:16-2:16 14 + Amount j:2:16-2:25 15 15 Quantity: 20 16 - Commodity: "UAH" 16 + Commodity: "UAH" j:2:16-2:19 17 17 CommodityPos: Before 18 18 HasSpace: true 19 19 Precision: 2 20 20 Decimal: "." 21 21 Cost(unit) j:2:26-2:33 22 - Amount j:2:28-2:28 22 + Amount j:2:28-2:33 23 23 Quantity: 1 24 - Commodity: "USD" 24 + Commodity: "USD" j:2:30-2:33 25 25 CommodityPos: After 26 26 HasSpace: true 27 27 Precision: 0 ··· 29 29 BalanceAssertion j:2:34-3:0 30 30 IsStrict: false 31 31 IsInclusive: false 32 - Amount j:2:36-2:36 32 + Amount j:2:36-3:0 33 33 Quantity: 0 34 - Commodity: "UAH" 34 + Commodity: "UAH" j:2:38-3:0 35 35 CommodityPos: After 36 36 HasSpace: true 37 37 Precision: 0
M
journal/parser/testdata/transaction_with_costs.txtar
··· 23 23 Account j:2:2-2:14 24 24 SubAccount: "expenses" j:2:2-2:10 25 25 SubAccount: "atm" j:2:11-2:14 26 - Amount j:2:16-2:16 26 + Amount j:2:16-2:25 27 27 Quantity: 20 28 - Commodity: "UAH" 28 + Commodity: "UAH" j:2:22-2:25 29 29 CommodityPos: After 30 30 HasSpace: true 31 31 Precision: 2 32 32 Decimal: "." 33 33 Cost(unit) j:2:26-3:0 34 - Amount j:2:28-2:28 34 + Amount j:2:28-3:0 35 35 Quantity: 1 36 - Commodity: "USD" 36 + Commodity: "USD" j:2:30-3:0 37 37 CommodityPos: After 38 38 HasSpace: true 39 39 Precision: 0 ··· 51 51 Account j:6:2-6:14 52 52 SubAccount: "expenses" j:6:2-6:10 53 53 SubAccount: "atm" j:6:11-6:14 54 - Amount j:6:16-6:16 54 + Amount j:6:16-6:25 55 55 Quantity: 20 56 - Commodity: "UAH" 56 + Commodity: "UAH" j:6:22-6:25 57 57 CommodityPos: After 58 58 HasSpace: true 59 59 Precision: 2 60 60 Decimal: "." 61 61 Cost(total) j:6:26-7:0 62 - Amount j:6:29-6:29 62 + Amount j:6:29-7:0 63 63 Quantity: 1 64 - Commodity: "USD" 64 + Commodity: "USD" j:6:31-7:0 65 65 CommodityPos: After 66 66 HasSpace: true 67 67 Precision: 0 ··· 79 79 Account j:10:2-10:14 80 80 SubAccount: "expenses" j:10:2-10:10 81 81 SubAccount: "atm" j:10:11-10:14 82 - Amount j:10:16-10:16 82 + Amount j:10:16-10:25 83 83 Quantity: 20 84 - Commodity: "UAH" 84 + Commodity: "UAH" j:10:22-10:25 85 85 CommodityPos: After 86 86 HasSpace: true 87 87 Precision: 2 88 88 Decimal: "." 89 89 Cost(unit) j:10:26-10:33 90 - Amount j:10:28-10:28 90 + Amount j:10:28-10:33 91 91 Quantity: 1 92 - Commodity: "USD" 92 + Commodity: "USD" j:10:30-10:33 93 93 CommodityPos: After 94 94 HasSpace: true 95 95 Precision: 0 ··· 97 97 BalanceAssertion j:10:34-11:0 98 98 IsStrict: false 99 99 IsInclusive: false 100 - Amount j:10:36-10:36 100 + Amount j:10:36-10:45 101 101 Quantity: 20 102 - Commodity: "UAH" 102 + Commodity: "UAH" j:10:42-10:45 103 103 CommodityPos: After 104 104 HasSpace: true 105 105 Precision: 2 106 106 Decimal: "." 107 107 Cost(unit) j:10:46-11:0 108 - Amount j:10:48-10:48 108 + Amount j:10:48-11:0 109 109 Quantity: 1 110 - Commodity: "USD" 110 + Commodity: "USD" j:10:50-11:0 111 111 CommodityPos: After 112 112 HasSpace: true 113 113 Precision: 0 ··· 125 125 Account j:14:5-14:16 126 126 SubAccount: "assets" j:14:5-14:11 127 127 SubAccount: "cash" j:14:12-14:16 128 - Amount j:14:18-14:18 128 + Amount j:14:18-14:25 129 129 Quantity: -20 130 - Commodity: "EUR" 130 + Commodity: "EUR" j:14:22-14:25 131 131 CommodityPos: After 132 132 HasSpace: true 133 133 Precision: 0 134 134 Decimal: "." 135 135 Cost(unit) j:14:26-15:0 136 - Amount j:14:28-14:28 136 + Amount j:14:28-15:0 137 137 Quantity: 7.53 138 - Commodity: "HRK" 138 + Commodity: "HRK" j:14:33-15:0 139 139 CommodityPos: After 140 140 HasSpace: true 141 141 Precision: 2 ··· 144 144 Account j:15:5-15:16 145 145 SubAccount: "assets" j:15:5-15:11 146 146 SubAccount: "cash" j:15:12-15:16 147 - Amount j:15:18-15:18 147 + Amount j:15:18-16:0 148 148 Quantity: 150.6 149 - Commodity: "HRK" 149 + Commodity: "HRK" j:15:25-16:0 150 150 CommodityPos: After 151 151 HasSpace: true 152 152 Precision: 2
M
journal/parser/testdata/transaction_with_digit_payees.txtar
··· 15 15 Posting j:2:1-3:1 16 16 Account j:2:5-2:45 17 17 SubAccount: "af0628973ff35bd62ddb048fa41dd8d83c1c46fe" j:2:5-2:45 18 - Amount j:2:52-2:52 18 + Amount j:2:52-3:0 19 19 Quantity: 474.31 20 - Commodity: "$" 20 + Commodity: "$" j:2:52-2:53 21 21 CommodityPos: Before 22 22 HasSpace: false 23 23 Precision: 2 ··· 34 34 Posting j:6:1-7:1 35 35 Account j:6:5-6:45 36 36 SubAccount: "0ecbb1b15e2cf3e515cc0f8533e5bb0fb2326728" j:6:5-6:45 37 - Amount j:6:53-6:53 37 + Amount j:6:53-7:0 38 38 Quantity: 14.91 39 - Commodity: "$" 39 + Commodity: "$" j:6:53-6:54 40 40 CommodityPos: Before 41 41 HasSpace: false 42 42 Precision: 2
M
journal/parser/testdata/transaction_with_header_comment.txtar
··· 16 16 Account j:3:2-3:15 17 17 SubAccount: "expenses" j:3:2-3:10 18 18 SubAccount: "food" j:3:11-3:15 19 - Amount j:3:17-3:17 19 + Amount j:3:17-4:0 20 20 Quantity: 10 21 - Commodity: "$" 21 + Commodity: "$" j:3:17-3:18 22 22 CommodityPos: Before 23 23 HasSpace: false 24 24 Precision: 2
M
journal/parser/testdata/transaction_with_inline_comment.txtar
··· 11 11 Account j:2:2-2:15 12 12 SubAccount: "Expenses" j:2:2-2:10 13 13 SubAccount: "Good" j:2:11-2:15 14 - Amount j:2:17-2:17 14 + Amount j:2:17-2:23 15 15 Quantity: 10 16 - Commodity: "$" 16 + Commodity: "$" j:2:17-2:18 17 17 CommodityPos: Before 18 18 HasSpace: false 19 19 Precision: 2
M
journal/parser/testdata/transaction_with_posting.txtar
··· 11 11 Account j:2:5-2:18 12 12 SubAccount: "expenses" j:2:5-2:13 13 13 SubAccount: "food" j:2:14-2:18 14 - Amount j:2:20-2:20 14 + Amount j:2:20-3:0 15 15 Quantity: 10 16 - Commodity: "$" 16 + Commodity: "$" j:2:20-2:21 17 17 CommodityPos: Before 18 18 HasSpace: false 19 19 Precision: 2
M
journal/parser/testdata/transaction_with_posting_amounts.txtar
··· 11 11 Account j:2:5-2:18 12 12 SubAccount: "expenses" j:2:5-2:13 13 13 SubAccount: "food" j:2:14-2:18 14 - Amount j:2:20-2:20 14 + Amount j:2:20-3:0 15 15 Quantity: 10 16 - Commodity: "UAH" 16 + Commodity: "UAH" j:2:26-3:0 17 17 CommodityPos: After 18 18 HasSpace: true 19 19 Precision: 2 ··· 22 22 Account j:3:5-3:20 23 23 SubAccount: "assets" j:3:5-3:11 24 24 SubAccount: "checking" j:3:12-3:20 25 - Amount j:3:22-3:22 25 + Amount j:3:22-4:0 26 26 Quantity: -10 27 - Commodity: "UAH" 27 + Commodity: "UAH" j:3:29-4:0 28 28 CommodityPos: After 29 29 HasSpace: true 30 30 Precision: 2
M
journal/parser/testdata/transaction_with_quoted_commodity.txtar
··· 11 11 Account j:2:5-2:18 12 12 SubAccount: "expenses" j:2:5-2:13 13 13 SubAccount: "food" j:2:14-2:18 14 - Amount j:2:20-2:20 14 + Amount j:2:20-3:0 15 15 Quantity: 10 16 - Commodity: "EUR" 16 + Commodity: "EUR" j:2:26-3:0 17 17 CommodityPos: After 18 18 HasSpace: true 19 19 Precision: 2
M
journal/parser/testdata/transaction_with_spaced_account_name.txtar
··· 10 10 Posting j:2:1-3:1 11 11 Account j:2:5-2:11 12 12 SubAccount: "assets" j:2:5-2:11 13 - Amount j:2:31-2:31 13 + Amount j:2:31-2:34 14 14 Quantity: 21 15 15 Commodity: "" 16 16 CommodityPos: Before ··· 20 20 BalanceAssertion j:2:34-3:0 21 21 IsStrict: false 22 22 IsInclusive: false 23 - Amount j:2:36-2:36 23 + Amount j:2:36-3:0 24 24 Quantity: 21 25 25 Commodity: "" 26 26 CommodityPos: Before
M
journal/parser/testdata/transaction_with_strange_commodity_symbols.txtar
··· 17 17 Posting j:3:1-4:1 18 18 Account j:3:3-3:7 19 19 SubAccount: "asdf" j:3:3-3:7 20 - Amount j:3:9-3:9 20 + Amount j:3:9-4:0 21 21 Quantity: 123 22 - Commodity: "$€£" 22 + Commodity: "$€£" j:3:13-4:0 23 23 CommodityPos: After 24 24 HasSpace: true 25 25 Precision: 0 ··· 34 34 Posting j:7:1-8:1 35 35 Account j:7:3-7:7 36 36 SubAccount: "asdf" j:7:3-7:7 37 - Amount j:7:9-7:9 37 + Amount j:7:9-8:0 38 38 Quantity: 123 39 - Commodity: "bytes" 39 + Commodity: "bytes" j:7:13-8:0 40 40 CommodityPos: After 41 41 HasSpace: true 42 42 Precision: 0
M
journal/parser/testdata/transaction_with_tabs.txtar
··· 11 11 Account j:2:2-2:15 12 12 SubAccount: "expenses" j:2:2-2:10 13 13 SubAccount: "food" j:2:11-2:15 14 - Amount j:2:17-2:17 14 + Amount j:2:17-3:0 15 15 Quantity: 10 16 - Commodity: "$" 16 + Commodity: "$" j:2:17-2:18 17 17 CommodityPos: Before 18 18 HasSpace: false 19 19 Precision: 2
M
journal/parser/testdata/transaction_with_trailing_indent.txtar
··· 16 16 SubAccount: "expenses" j:3:5-3:13 17 17 SubAccount: "personal" j:3:14-3:22 18 18 SubAccount: "tax" j:3:23-3:26 19 - Amount j:3:40-3:40 19 + Amount j:3:40-4:0 20 20 Quantity: 1250 21 - Commodity: "$" 21 + Commodity: "$" j:3:40-3:41 22 22 CommodityPos: Before 23 23 HasSpace: false 24 24 Precision: 0 ··· 28 28 SubAccount: "assets" j:4:5-4:11 29 29 SubAccount: "bank" j:4:12-4:16 30 30 SubAccount: "checking" j:4:17-4:25 31 - Amount j:4:40-4:40 31 + Amount j:4:40-5:0 32 32 Quantity: -1250 33 - Commodity: "$" 33 + Commodity: "$" j:4:40-4:41 34 34 CommodityPos: Before 35 35 HasSpace: false 36 36 Precision: 0
M
journal/parser/testdata/transaction_with_unicode_commodity_symbols.txtar
··· 13 13 Account j:2:5-2:18 14 14 SubAccount: "expenses" j:2:5-2:13 15 15 SubAccount: "food" j:2:14-2:18 16 - Amount j:2:20-2:20 16 + Amount j:2:20-3:0 17 17 Quantity: 10 18 - Commodity: "€" 18 + Commodity: "€" j:2:20-2:21 19 19 CommodityPos: Before 20 20 HasSpace: false 21 21 Precision: 2 ··· 24 24 Account j:3:5-3:18 25 25 SubAccount: "expenses" j:3:5-3:13 26 26 SubAccount: "food" j:3:14-3:18 27 - Amount j:3:20-3:20 27 + Amount j:3:20-4:0 28 28 Quantity: 5 29 - Commodity: "£" 29 + Commodity: "£" j:3:20-3:21 30 30 CommodityPos: Before 31 31 HasSpace: false 32 32 Precision: 2 ··· 35 35 Account j:4:5-4:18 36 36 SubAccount: "expenses" j:4:5-4:13 37 37 SubAccount: "food" j:4:14-4:18 38 - Amount j:4:20-4:20 38 + Amount j:4:20-5:0 39 39 Quantity: 700 40 - Commodity: "₹" 40 + Commodity: "₹" j:4:20-4:21 41 41 CommodityPos: Before 42 42 HasSpace: false 43 43 Precision: 2
M
journal/parser/testdata/transaction_with_virtual_accounts.txtar
··· 13 13 Account j:2:3-2:18 14 14 SubAccount: "virtual" j:2:3-2:10 15 15 SubAccount: "account" j:2:11-2:18 16 - Amount j:2:21-2:21 16 + Amount j:2:21-3:0 17 17 Quantity: 1 18 - Commodity: "PESO" 18 + Commodity: "PESO" j:2:23-3:0 19 19 CommodityPos: After 20 20 HasSpace: true 21 21 Precision: 0 ··· 25 25 Account j:3:3-3:17 26 26 SubAccount: "something" j:3:3-3:12 27 27 SubAccount: "else" j:3:13-3:17 28 - Amount j:3:21-3:21 28 + Amount j:3:21-4:0 29 29 Quantity: 5 30 - Commodity: "PESO" 30 + Commodity: "PESO" j:3:23-4:0 31 31 CommodityPos: After 32 32 HasSpace: true 33 33 Precision: 0
M
journal/parser/testdata/virtual_postings_with_statuses.txtar
··· 21 21 Account j:2:6-2:17 22 22 SubAccount: "assets" j:2:6-2:12 23 23 SubAccount: "cash" j:2:13-2:17 24 - Amount j:2:20-2:20 24 + Amount j:2:20-3:0 25 25 Quantity: 10 26 - Commodity: "$" 26 + Commodity: "$" j:2:20-2:21 27 27 CommodityPos: Before 28 28 HasSpace: false 29 29 Precision: 0 ··· 33 33 Account j:3:4-3:15 34 34 SubAccount: "income" j:3:4-3:10 35 35 SubAccount: "gift" j:3:11-3:15 36 - Amount j:3:18-3:18 36 + Amount j:3:18-4:0 37 37 Quantity: -10 38 - Commodity: "$" 38 + Commodity: "$" j:3:18-3:19 39 39 CommodityPos: Before 40 40 HasSpace: false 41 41 Precision: 0 ··· 50 50 Account j:6:8-6:19 51 51 SubAccount: "assets" j:6:8-6:14 52 52 SubAccount: "cash" j:6:15-6:19 53 - Amount j:6:22-6:22 53 + Amount j:6:22-7:0 54 54 Quantity: 10 55 - Commodity: "$" 55 + Commodity: "$" j:6:22-6:23 56 56 CommodityPos: Before 57 57 HasSpace: false 58 58 Precision: 0 ··· 62 62 Account j:7:6-7:17 63 63 SubAccount: "income" j:7:6-7:12 64 64 SubAccount: "gift" j:7:13-7:17 65 - Amount j:7:20-7:20 65 + Amount j:7:20-8:0 66 66 Quantity: -10 67 - Commodity: "$" 67 + Commodity: "$" j:7:20-7:21 68 68 CommodityPos: Before 69 69 HasSpace: false 70 70 Precision: 0 ··· 79 79 Account j:10:10-10:21 80 80 SubAccount: "assets" j:10:10-10:16 81 81 SubAccount: "cash" j:10:17-10:21 82 - Amount j:10:24-10:24 82 + Amount j:10:24-11:0 83 83 Quantity: 10 84 - Commodity: "$" 84 + Commodity: "$" j:10:24-10:25 85 85 CommodityPos: Before 86 86 HasSpace: false 87 87 Precision: 0 ··· 92 92 Account j:11:8-11:19 93 93 SubAccount: "income" j:11:8-11:14 94 94 SubAccount: "gift" j:11:15-11:19 95 - Amount j:11:22-11:22 95 + Amount j:11:22-12:0 96 96 Quantity: -10 97 - Commodity: "$" 97 + Commodity: "$" j:11:22-11:23 98 98 CommodityPos: Before 99 99 HasSpace: false 100 100 Precision: 0
M
journal/printer/transaction.go
··· 21 21 } 22 22 23 23 // code 24 - if t.Code != nil && *t.Code != "" { 24 + if t.Code != nil && t.Code.Value != "" { 25 25 p.buf.WriteString(" (") 26 - p.buf.WriteString(*t.Code) 26 + p.buf.WriteString(t.Code.Value) 27 27 p.buf.WriteByte(')') 28 28 } 29 29 ··· 34 34 } 35 35 36 36 // note 37 - if t.Note != nil && *t.Note != "" { 37 + if t.Note != nil && t.Note.Value != "" { 38 38 p.buf.WriteString(" | ") 39 - p.buf.WriteString(*t.Note) 39 + p.buf.WriteString(t.Note.Value) 40 40 } 41 41 42 42 p.writeComment(t.Comment) ··· 69 69 } 70 70 71 71 // code 72 - if t.Code != nil && *t.Code != "" { 72 + if t.Code != nil && t.Code.Value != "" { 73 73 p.buf.WriteString(" (") 74 - p.buf.WriteString(*t.Code) 74 + p.buf.WriteString(t.Code.Value) 75 75 p.buf.WriteByte(')') 76 76 } 77 77 78 78 // description 79 - if t.Description != nil && *t.Description != "" { 79 + if t.Description != nil && t.Description.Value != "" { 80 80 p.buf.WriteByte(' ') 81 - p.buf.WriteString(*t.Description) 81 + p.buf.WriteString(t.Description.Value) 82 82 } 83 83 84 84 // comment ··· 100 100 p.buf.WriteByte('=') 101 101 102 102 // expression 103 - if t.Expr != "" { 103 + if t.Expr.Value != "" { 104 104 p.buf.WriteByte(' ') 105 - p.buf.WriteString(t.Expr) 105 + p.buf.WriteString(t.Expr.Value) 106 106 } 107 107 108 108 // comment