all repos

clerk @ a7ca45c

missing tooling for ledger/hledger

clerk/journal/parser/parser.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
journal: parse the conversion value, 1 month ago
1
package parser
2
3
import (
4
	"fmt"
5
	"strconv"
6
	"strings"
7
8
	"olexsmir.xyz/clerk/internal/decimal"
9
	"olexsmir.xyz/clerk/journal/ast"
10
	"olexsmir.xyz/clerk/journal/lexer"
11
	"olexsmir.xyz/clerk/journal/token"
12
)
13
14
type Parser struct {
15
	lexer  *lexer.Lexer
16
	errors []*ast.ParseError
17
	cur    token.Token
18
	peek   token.Token
19
}
20
21
func New(lex *lexer.Lexer) *Parser {
22
	p := &Parser{lexer: lex}
23
	p.advance() // populate .peek
24
	p.advance() // populate .cur
25
	return p
26
}
27
28
func (p *Parser) ParseJournal() *ast.Journal {
29
	f := &ast.Journal{}
30
	for p.cur.Type != token.EOF {
31
		if e := p.parseEntry(); e != nil {
32
			f.Entries = append(f.Entries, e)
33
		}
34
	}
35
	f.Errors = p.errors
36
	return f
37
}
38
39
func isDirectiveKeyword(t token.Type) bool {
40
	switch t {
41
	case token.COMMENTKW, token.ACCOUNT, token.COMMODITY, token.INCLUDE,
42
		token.ALIAS, token.PAYEE, token.TAG, token.APPLY, token.END,
43
		token.YEAR, token.DECIMALMARK, token.D, token.P, token.N, token.C:
44
		return true
45
	}
46
	return false
47
}
48
49
func (p *Parser) parseEntry() ast.Entry {
50
	if p.got(token.BANG) || p.got(token.AT) {
51
		if isDirectiveKeyword(p.peek.Type) {
52
			p.advance() // consume prefix
53
		}
54
	}
55
	switch p.cur.Type {
56
	case token.ILLEGAL:
57
		p.errorf("illegal character %q", p.cur.Literal)
58
		p.advance()
59
		return nil
60
	case token.INDENT:
61
		p.errorf("unexpected indent")
62
		p.syncToNextline()
63
		return nil
64
	case token.DATE:
65
		return p.parseTransaction()
66
	case token.TILDE:
67
		return p.parsePeriodicTransaction()
68
	case token.EQ:
69
		return p.parseAutomatedTransaction()
70
	case token.NEWLINE:
71
		return p.parseBlankLine()
72
	case token.SEMICOLON, token.HASH, token.PERCENT, token.STAR:
73
		return p.parseComment()
74
	case token.ACCOUNT:
75
		return p.parseAccountDirective()
76
	case token.COMMODITY:
77
		return p.parseCommodityDirective()
78
	case token.INCLUDE:
79
		return p.parseIncludeDirective()
80
	case token.ALIAS:
81
		return p.parseAliasDirective()
82
	case token.PAYEE:
83
		return p.parsePayeeDirective()
84
	case token.TAG:
85
		return p.parseTagDirective()
86
	case token.YEAR:
87
		return p.parseYearDirective()
88
	case token.DECIMALMARK:
89
		return p.parseDecimalMarkDirective()
90
	case token.D:
91
		return p.parseDefaultCommodityDirective()
92
	case token.P:
93
		return p.parseMarketPriceDirective()
94
	case token.N:
95
		return p.parseIgnoredDirective()
96
	case token.C:
97
		return p.parseConversionDirective()
98
	case token.APPLY:
99
		return p.parseApplyDirective()
100
	case token.END:
101
		return p.parseEndDirective()
102
	case token.COMMENTKW:
103
		return p.parseCommentBlockDirective()
104
	default:
105
		p.errorf("unexpected token %s", p.cur.Type)
106
		p.sync()
107
		return nil
108
	}
109
}
110
111
func (p *Parser) parseTransaction() *ast.Transaction {
112
	s := p.cur.Span
113
	tx := &ast.Transaction{}
114
115
	tx.Date = p.parseDate()
116
117
	p.skipWhitespace()
118
119
	// optional secondary date
120
	if p.got(token.EQ) {
121
		p.advance()
122
		p.skipWhitespace()
123
		d := p.parseDate()
124
		tx.SecondDate = &d
125
	}
126
127
	p.skipWhitespace()
128
129
	// optional status
130
	tx.Status = p.parseStatus()
131
132
	// optional code
133
	if p.got(token.LPAREN) {
134
		p.advance()
135
		var code strings.Builder
136
		for p.cur.Type != token.RPAREN {
137
			_, _ = code.WriteString(p.cur.Literal)
138
			p.advance()
139
		}
140
		tx.Code = new(code.String())
141
		p.advance() // TODO: why?
142
		p.skipWhitespace()
143
	}
144
145
	// optional payee | note
146
	if p.got(token.TEXT) || p.got(token.STRING) {
147
		tx.Payee = p.parsePayee()
148
149
		// check for | separator
150
		if p.got(token.WHITESPACE) {
151
			p.skipWhitespace()
152
		}
153
154
		if p.got(token.PIPE) {
155
			p.advance()
156
			if p.got(token.TEXT) {
157
				n := p.cur.Literal
158
				p.advance()
159
				tx.Note = &n
160
			}
161
		}
162
	}
163
164
	tx.Comment = p.parseOptInlineComment()
165
	p.expectNewline()
166
167
	// header comments — indented ; lines before first posting
168
	for p.got(token.INDENT) && p.willGet(token.SEMICOLON) {
169
		p.advance() // consume indent
170
		c := p.parseComment()
171
		tx.HeaderComments = append(tx.HeaderComments, c)
172
	}
173
174
	// postings
175
	for p.got(token.INDENT) {
176
		if p := p.parsePosting(); p != nil {
177
			tx.Postings = append(tx.Postings, p)
178
		}
179
	}
180
181
	tx.Span = p.span(s)
182
	return tx
183
}
184
185
func unquote(s string) string {
186
	if len(s) >= 2 && ((s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')) {
187
		return s[1 : len(s)-1]
188
	}
189
	return s
190
}
191
192
func (p *Parser) parsePayee() *ast.Payee {
193
	s := p.cur.Span
194
195
	if p.got(token.STRING) {
196
		name := unquote(p.cur.Literal)
197
		p.advance()
198
		return &ast.Payee{Name: name, Span: p.span(s)}
199
	}
200
201
	// keep spaces/tags between text tokens; stop before trailing whitespace
202
	var name strings.Builder
203
	for p.got(token.TEXT) || p.got(token.INT) || p.got(token.DECIMAL) || (p.got(token.WHITESPACE) && (p.willGet(token.TEXT) || p.willGet(token.INT) || p.willGet(token.DECIMAL))) {
204
		_, _ = name.WriteString(p.cur.Literal)
205
		p.advance()
206
	}
207
	return &ast.Payee{Name: unquote(name.String()), Span: p.span(s)}
208
}
209
210
func (p *Parser) parsePeriodicTransaction() *ast.PeriodicTransaction {
211
	s := p.cur.Span
212
	p.expect(token.TILDE)
213
	p.skipWhitespace()
214
215
	pt := &ast.PeriodicTransaction{}
216
217
	pt.Span = p.span(s)
218
	pt.Period = p.parsePeriod()
219
220
	if desc := p.parseOptPeriodicDescription(); desc != "" {
221
		pt.Description = &desc
222
	}
223
224
	comment := p.parseOptInlineComment()
225
	p.expectNewline()
226
227
	var headerComments []*ast.Comment
228
	var postings []*ast.Posting
229
	for p.got(token.INDENT) || p.got(token.SEMICOLON) {
230
		if p.got(token.SEMICOLON) {
231
			c := p.parseComment()
232
			headerComments = append(headerComments, c)
233
			continue
234
		}
235
		posting := p.parsePosting()
236
		if posting != nil {
237
			postings = append(postings, posting)
238
		}
239
	}
240
241
	pt.HeaderComments = headerComments
242
	pt.Postings = postings
243
	pt.Comment = comment
244
	return pt
245
}
246
247
func (p *Parser) parseAutomatedTransaction() *ast.AutomatedTransaction {
248
	s := p.cur.Span
249
	p.expect(token.EQ)
250
	p.skipWhitespace()
251
252
	at := &ast.AutomatedTransaction{}
253
	at.Span = p.span(s)
254
255
	at.Expr = p.parseDirectiveExpr()
256
	at.Comment = p.parseOptInlineComment()
257
	p.expectNewline()
258
259
	// header comments
260
	for p.got(token.INDENT) && p.willGet(token.SEMICOLON) {
261
		p.advance()
262
		at.HeaderComments = append(at.HeaderComments, p.parseComment())
263
	}
264
265
	// postings
266
	for p.got(token.INDENT) {
267
		if p := p.parsePosting(); p != nil {
268
			at.Postings = append(at.Postings, p)
269
		}
270
	}
271
272
	return at
273
}
274
275
func (p *Parser) parsePeriod() ast.Period {
276
	s := p.cur.Span
277
278
	var periodBuf strings.Builder
279
280
	for !p.got(token.NEWLINE) && !p.got(token.EOF) &&
281
		!p.got(token.SEMICOLON) && !p.got(token.HASH) && !p.got(token.PERCENT) && !p.got(token.STAR) {
282
283
		if p.got(token.WHITESPACE) {
284
			if len(p.cur.Literal) >= 2 {
285
				break
286
			}
287
			if p.willGet(token.NEWLINE) || p.willGet(token.EOF) ||
288
				p.willGet(token.SEMICOLON) || p.willGet(token.HASH) ||
289
				p.willGet(token.PERCENT) || p.willGet(token.STAR) {
290
				p.advance()
291
				continue
292
			}
293
		}
294
295
		periodBuf.WriteString(p.cur.Literal)
296
		p.advance()
297
	}
298
299
	str := periodBuf.String()
300
	period := ast.Period{Raw: str, Span: p.span(s)}
301
302
	if _, after, ok := strings.Cut(str, " from "); ok {
303
		end := strings.Index(after, " ")
304
		dateStr := after
305
		if end >= 0 {
306
			dateStr = after[:end]
307
		}
308
		if d := parseSimpleDate(dateStr); d.Year > 0 {
309
			period.From = &d
310
			rest := after
311
			if end >= 0 {
312
				rest = after[end:]
313
			}
314
			if _, toAfter, ok := strings.Cut(rest, " to "); ok {
315
				if toEnd := strings.Index(toAfter, " "); toEnd >= 0 {
316
					toAfter = toAfter[:toEnd]
317
				}
318
				if d := parseSimpleDate(toAfter); d.Year > 0 {
319
					period.To = &d
320
				}
321
			}
322
		}
323
	}
324
	return period
325
}
326
327
func (p *Parser) parseComment() *ast.Comment {
328
	s := p.cur.Span
329
	marker := p.cur.Literal[0]
330
	p.advance()
331
	p.skipWhitespace()
332
333
	var text string
334
	if p.got(token.TEXT) {
335
		text = p.cur.Literal
336
		p.advance()
337
	}
338
339
	p.expectNewline()
340
341
	return &ast.Comment{
342
		Marker: marker,
343
		Text:   text,
344
		Span:   p.span(s),
345
	}
346
}
347
348
func (p *Parser) parseAccountDirective() *ast.AccountDirective {
349
	s := p.cur.Span
350
	p.expect(token.ACCOUNT)
351
	p.skipWhitespace()
352
353
	account := p.parseAccount()
354
	comment := p.parseOptInlineComment()
355
	p.expectNewline()
356
357
	for p.got(token.INDENT) {
358
		p.advance()
359
		for !p.got(token.NEWLINE) && !p.got(token.EOF) {
360
			p.advance()
361
		}
362
		p.expectNewline()
363
	}
364
365
	return &ast.AccountDirective{
366
		Account: account,
367
		Comment: comment,
368
		Span:    p.span(s),
369
	}
370
}
371
372
func (p *Parser) parseCommodityDirective() *ast.CommodityDirective {
373
	s := p.cur.Span
374
	p.expect(token.COMMODITY)
375
	p.skipWhitespace()
376
377
	var commodity string
378
	var format *ast.Amount
379
380
	switch p.cur.Type {
381
	case token.COMMODITYMARK, token.TEXT, token.STRING:
382
		commodity = p.cur.Literal
383
		if p.got(token.STRING) {
384
			commodity = unquote(commodity)
385
		}
386
		p.advance()
387
		hadSpace := p.got(token.WHITESPACE)
388
		p.skipWhitespace()
389
		if p.got(token.INT) || p.got(token.DECIMAL) || p.got(token.TEXT) {
390
			format = p.parseAmount()
391
			format.Commodity = commodity
392
			format.CommodityPos = ast.CommodityBefore
393
			format.HasSpace = hadSpace
394
		}
395
	case token.INT, token.DECIMAL:
396
		format = p.parseAmount()
397
		commodity = format.Commodity
398
	default:
399
		p.errorf("expected commodity name or amount, got %s", p.cur.Type)
400
	}
401
402
	if commodity == "" {
403
		p.errorf("expected commodity name, got %s", p.cur.Type)
404
	}
405
406
	comment := p.parseOptInlineComment()
407
	p.expectNewline()
408
409
	for p.got(token.INDENT) {
410
		p.advance()
411
		p.skipWhitespace()
412
		if p.got(token.COMMODITYMARK) && p.cur.Literal == "format" {
413
			p.advance()
414
			p.skipWhitespace()
415
			format = p.parseAmount()
416
		} else {
417
			for !p.got(token.NEWLINE) && !p.got(token.EOF) {
418
				p.advance()
419
			}
420
		}
421
		p.expectNewline()
422
	}
423
424
	cd := &ast.CommodityDirective{
425
		Commodity: commodity,
426
		Comment:   comment,
427
		Span:      p.span(s),
428
	}
429
	if format != nil {
430
		cd.Format = *format
431
	}
432
	return cd
433
}
434
435
func (p *Parser) parseIncludeDirective() *ast.IncludeDirective {
436
	s := p.cur.Span
437
	p.expect(token.INCLUDE)
438
	p.skipWhitespace()
439
440
	id := &ast.IncludeDirective{}
441
442
	if p.got(token.TEXT) {
443
		id.Path = p.cur.Literal
444
		p.advance()
445
	} else {
446
		p.errorf("expected file path, got %s", p.cur.Type)
447
	}
448
449
	p.skipWhitespace()
450
	id.Comment = p.parseOptInlineComment()
451
	p.expectNewline()
452
	id.Span = p.span(s)
453
	return id
454
}
455
456
func (p *Parser) parseAliasDirective() *ast.AliasDirective {
457
	s := p.cur.Span
458
	alias := &ast.AliasDirective{}
459
	p.expect(token.ALIAS)
460
	p.skipWhitespace()
461
	alias.From = p.parseAccount()
462
	p.skipWhitespace()
463
	p.expect(token.EQ)
464
	p.skipWhitespace()
465
	alias.To = p.parseAccount()
466
	p.skipWhitespace()
467
	alias.Comment = p.parseOptInlineComment()
468
	p.expectNewline()
469
	alias.Span = p.span(s)
470
	return alias
471
}
472
473
func (p *Parser) parsePayeeDirective() *ast.PayeeDirective {
474
	s := p.cur.Span
475
	p.expect(token.PAYEE)
476
	p.skipWhitespace()
477
478
	name := ""
479
	if p.got(token.TEXT) || p.got(token.STRING) {
480
		name = p.parsePayee().Name
481
	}
482
483
	comment := p.parseOptInlineComment()
484
	p.expectNewline()
485
486
	return &ast.PayeeDirective{
487
		Name:    name,
488
		Comment: comment,
489
		Span:    p.span(s),
490
	}
491
}
492
493
func (p *Parser) parseTagDirective() *ast.TagDirective {
494
	s := p.cur.Span
495
	p.expect(token.TAG)
496
	p.skipWhitespace()
497
498
	name := ""
499
	if p.got(token.TEXT) {
500
		name = p.cur.Literal
501
		p.advance()
502
	}
503
504
	comment := p.parseOptInlineComment()
505
	p.expectNewline()
506
507
	return &ast.TagDirective{
508
		Name:    name,
509
		Comment: comment,
510
		Span:    p.span(s),
511
	}
512
}
513
514
func (p *Parser) parseYearDirective() *ast.YearDirective {
515
	s := p.cur.Span
516
	year := &ast.YearDirective{}
517
	p.expect(token.YEAR)
518
	p.skipWhitespace()
519
520
	if p.got(token.INT) {
521
		year.Year, _ = strconv.Atoi(p.cur.Literal)
522
		p.advance()
523
	} else {
524
		p.errorf("expected year, got %s", p.cur.Type)
525
	}
526
527
	p.skipWhitespace()
528
	year.Comment = p.parseOptInlineComment()
529
	p.expectNewline()
530
	year.Span = p.span(s)
531
	return year
532
}
533
534
func (p *Parser) parseDecimalMarkDirective() *ast.DecimalMarkDirective {
535
	s := p.cur.Span
536
	mark := &ast.DecimalMarkDirective{}
537
	p.expect(token.DECIMALMARK)
538
	p.skipWhitespace()
539
540
	mark.Mark = byte('.')
541
	if p.got(token.TEXT) {
542
		if len(p.cur.Literal) > 0 {
543
			mark.Mark = p.cur.Literal[0]
544
		}
545
		p.advance()
546
	}
547
548
	p.skipWhitespace()
549
	mark.Comment = p.parseOptInlineComment()
550
	p.expectNewline()
551
	mark.Span = p.span(s)
552
	return mark
553
}
554
555
func (p *Parser) parseDefaultCommodityDirective() *ast.DefaultCommodityDirective {
556
	s := p.cur.Span
557
	com := &ast.DefaultCommodityDirective{}
558
	p.expect(token.D)
559
	p.skipWhitespace()
560
	com.Amount = *p.parseAmount()
561
	p.skipWhitespace()
562
	com.Comment = p.parseOptInlineComment()
563
	p.expectNewline()
564
	com.Span = p.span(s)
565
	return com
566
}
567
568
func (p *Parser) parseConversionDirective() *ast.ConversionDirective {
569
	s := p.cur.Span
570
	cd := &ast.ConversionDirective{}
571
	p.expect(token.C)
572
	p.skipWhitespace()
573
574
	if p.isAmountStart() {
575
		cd.From = *p.parseAmount()
576
	} else {
577
		p.errorf("expected amount, got %s", p.cur.Type)
578
	}
579
580
	p.skipWhitespace()
581
	if p.got(token.EQ) {
582
		p.advance()
583
		p.skipWhitespace()
584
		if p.isAmountStart() {
585
			cd.To = *p.parseAmount()
586
		} else {
587
			p.errorf("expected amount, got %s", p.cur.Type)
588
		}
589
	}
590
591
	p.skipWhitespace()
592
	cd.Comment = p.parseOptInlineComment()
593
	p.expectNewline()
594
	cd.Span = p.span(s)
595
	return cd
596
}
597
598
func (p *Parser) parseIgnoredDirective() *ast.IgnoredDirective {
599
	s := p.cur.Span
600
	p.expect(token.N)
601
	p.skipWhitespace()
602
603
	id := &ast.IgnoredDirective{}
604
	if p.got(token.TEXT) || p.got(token.COMMODITYMARK) {
605
		id.Text = p.cur.Literal
606
		p.advance()
607
	}
608
	p.skipWhitespace()
609
	id.Comment = p.parseOptInlineComment()
610
611
	p.expectNewline()
612
	id.Span = p.span(s)
613
	return id
614
}
615
616
func (p *Parser) parseMarketPriceDirective() *ast.MarketPriceDirective {
617
	s := p.cur.Span
618
	p.expect(token.P)
619
	p.skipWhitespace()
620
621
	mp := &ast.MarketPriceDirective{}
622
	mp.DateTime.Date = p.parseDate()
623
	p.skipWhitespace()
624
625
	if p.got(token.TIME) {
626
		mp.DateTime.Time = new(p.parseTime())
627
		p.skipWhitespace()
628
	}
629
630
	tok, _ := p.expect(token.COMMODITYMARK)
631
	mp.Commodity = tok.Literal
632
	p.advance()
633
634
	mp.Amount = *p.parseAmount()
635
636
	p.skipWhitespace()
637
	mp.Comment = p.parseOptInlineComment()
638
639
	p.expectNewline()
640
	mp.Span = p.span(s)
641
	return mp
642
}
643
644
func (p *Parser) parseTime() ast.Time {
645
	s := p.cur.Span
646
	tok, _ := p.expect(token.TIME)
647
	lit := tok.Literal
648
649
	parts := strings.Split(lit, ":")
650
	if len(parts) < 2 {
651
		p.errorf("invalid time format: %q", lit)
652
		return ast.Time{Span: p.span(s)}
653
	}
654
655
	hour, _ := strconv.Atoi(parts[0])
656
	minute, _ := strconv.Atoi(parts[1])
657
	second := 0
658
	if len(parts) > 2 {
659
		second, _ = strconv.Atoi(parts[2])
660
	}
661
662
	if hour < 0 || hour > 23 {
663
		p.errorf("invalid hour %d in time %q", hour, lit)
664
	}
665
	if minute < 0 || minute > 59 {
666
		p.errorf("invalid minute %d in time %q", minute, lit)
667
	}
668
	if second < 0 || second > 59 {
669
		p.errorf("invalid second %d in time %q", second, lit)
670
	}
671
672
	return ast.Time{
673
		Hour:   hour,
674
		Minute: minute,
675
		Second: second,
676
		Span:   p.span(s),
677
	}
678
}
679
680
func (p *Parser) parseApplyDirective() *ast.ApplyDirective {
681
	s := p.cur.Span
682
	p.expect(token.APPLY)
683
	p.skipWhitespace()
684
685
	expr := p.parseDirectiveExpr()
686
	comment := p.parseOptInlineComment()
687
	p.expectNewline()
688
689
	return &ast.ApplyDirective{
690
		Expr:    expr,
691
		Comment: comment,
692
		Span:    p.span(s),
693
	}
694
}
695
696
func (p *Parser) parseEndDirective() *ast.EndDirective {
697
	s := p.cur.Span
698
	p.expect(token.END)
699
	p.skipWhitespace()
700
701
	expr := p.parseDirectiveExpr()
702
	comment := p.parseOptInlineComment()
703
	p.expectNewline()
704
705
	return &ast.EndDirective{
706
		Expr:    expr,
707
		Comment: comment,
708
		Span:    p.span(s),
709
	}
710
}
711
712
func (p *Parser) parseCommentBlockDirective() *ast.CommentBlockDirective {
713
	start := p.cur.Span
714
	p.expect(token.COMMENTKW)
715
	p.skipWhitespace()
716
717
	header := p.parseDirectiveExpr()
718
	comment := p.parseOptInlineComment()
719
	p.expectNewline()
720
721
	var content strings.Builder
722
	for p.cur.Type != token.EOF {
723
		if p.got(token.END) {
724
			if p.willGet(token.NEWLINE) || p.willGet(token.EOF) {
725
				p.advance()
726
				p.expectNewline()
727
				break
728
			}
729
			if p.willGet(token.WHITESPACE) {
730
				endTok := p.cur
731
				p.advance()
732
				wsTok := p.cur
733
				p.advance()
734
				if p.got(token.TEXT) && p.cur.Literal == "comment" { // todo: this should check if it's an actual COMMENTKW token
735
					p.advance()
736
					p.parseDirectiveExpr()
737
					p.parseOptInlineComment()
738
					p.expectNewline()
739
					break
740
				}
741
				content.WriteString(endTok.Literal)
742
				content.WriteString(wsTok.Literal)
743
				continue
744
			}
745
		}
746
		content.WriteString(p.cur.Literal)
747
		p.advance()
748
	}
749
750
	return &ast.CommentBlockDirective{
751
		Header:  header,
752
		Content: content.String(),
753
		Comment: comment,
754
		Span:    p.span(start),
755
	}
756
}
757
758
func (p *Parser) parseStatus() *ast.Status {
759
	if p.got(token.STAR) || p.got(token.BANG) {
760
		status := ast.StatusPending
761
		if p.cur.Literal[0] == '*' {
762
			status = ast.StatusCleared
763
		}
764
		st := &ast.Status{Value: status, Span: p.cur.Span}
765
		p.advance()
766
		p.skipWhitespace()
767
		return st
768
	}
769
	return nil
770
}
771
772
func (p *Parser) isAmountStart() bool {
773
	switch p.cur.Type {
774
	default:
775
		return false
776
	case token.COMMODITYMARK, token.STRING, token.INT, token.DECIMAL, token.MINUS, token.PLUS, token.PARENEXPR:
777
		return true
778
	case token.TEXT:
779
		return len(p.cur.Literal) > 0 && p.cur.Literal[0] >= '0' && p.cur.Literal[0] <= '9'
780
	}
781
}
782
783
func (p *Parser) parseAmount() *ast.Amount {
784
	s := p.cur.Span
785
	amt := &ast.Amount{
786
		QuantityFmt: ast.QuantityFormat{Decimal: '.'},
787
		Span:        p.span(s),
788
	}
789
790
	// commodity before quantity: $10.00, eur 10.00
791
	if p.got(token.COMMODITYMARK) || p.got(token.TEXT) || p.got(token.STRING) {
792
		amt.Commodity = unquote(p.cur.Literal)
793
		amt.CommodityPos = ast.CommodityBefore
794
		p.advance()
795
		if p.got(token.WHITESPACE) {
796
			amt.HasSpace = true
797
			p.skipWhitespace()
798
		}
799
		switch p.cur.Type {
800
		case token.MINUS:
801
			amt.IsNegative = true
802
			p.advance()
803
		case token.PLUS:
804
			p.advance()
805
		}
806
		p.parseQuantityInto(amt)
807
	} else {
808
		// optional sign
809
		switch p.cur.Type {
810
		case token.MINUS:
811
			amt.IsNegative = true
812
			p.advance()
813
		case token.PLUS:
814
			p.advance()
815
		}
816
817
		// commodity before quantity: -$120, -eur 120:
818
		if p.got(token.COMMODITYMARK) || p.got(token.TEXT) || p.got(token.STRING) {
819
			amt.Commodity = unquote(p.cur.Literal)
820
			amt.CommodityPos = ast.CommodityBefore
821
			p.advance()
822
			if p.got(token.WHITESPACE) {
823
				amt.HasSpace = true
824
				p.skipWhitespace()
825
			}
826
		}
827
828
		p.parseQuantityInto(amt)
829
830
		// commodity after quantity: 10.00 UAH, 10.00 "EUR" (only if not set)
831
		if amt.Commodity == "" {
832
			switch p.cur.Type {
833
			case token.WHITESPACE:
834
				p.skipWhitespace()
835
				if p.got(token.COMMODITYMARK) || p.got(token.TEXT) || p.got(token.STRING) {
836
					amt.HasSpace = true
837
					amt.Commodity = unquote(p.cur.Literal)
838
					amt.CommodityPos = ast.CommodityAfter
839
					p.advance()
840
				}
841
			case token.COMMODITYMARK, token.TEXT, token.STRING:
842
				amt.Commodity = unquote(p.cur.Literal)
843
				amt.CommodityPos = ast.CommodityAfter
844
				p.advance()
845
			}
846
		}
847
	}
848
849
	return amt
850
}
851
852
func (p *Parser) parseAmountWithOptExpr() *ast.Amount {
853
	if p.got(token.STAR) {
854
		p.advance()
855
		p.skipWhitespace()
856
		amt := p.parseAmount()
857
		if amt != nil {
858
			amt.IsExpr = true
859
		}
860
		return amt
861
	}
862
	if p.got(token.PARENEXPR) {
863
		lit := p.cur.Literal
864
		amt := &ast.Amount{
865
			IsExpr:      true,
866
			QuantityFmt: ast.QuantityFormat{Decimal: '.'},
867
		}
868
		if len(lit) >= 2 && lit[0] == '(' && lit[len(lit)-1] == ')' {
869
			inner := lit[1 : len(lit)-1]
870
			i := 0
871
			for i < len(inner) && (inner[i] == ' ' || inner[i] == '\t') {
872
				i++
873
			}
874
			j := len(inner)
875
			for j > i && (inner[j-1] == ' ' || inner[j-1] == '\t') {
876
				j--
877
			}
878
			amt.Expr = inner[i:j]
879
		}
880
		amt.Span = p.cur.Span
881
		p.advance()
882
		return amt
883
	}
884
	return p.parseAmount()
885
}
886
887
func (p *Parser) parsePosting() *ast.Posting {
888
	s := p.cur.Span
889
	posting := &ast.Posting{}
890
	p.expect(token.INDENT)
891
892
	// exit if it's empty line
893
	if p.got(token.NEWLINE) || p.got(token.EOF) {
894
		p.syncToNextline()
895
		return nil
896
	}
897
898
	// optional status, outside of brackets, '! (account)'
899
	posting.Status = p.parseStatus()
900
901
	// detect virtual posting brackets
902
	switch p.cur.Type {
903
	case token.LPAREN:
904
		posting.Type = ast.PostingVirtualUnbalanced
905
		p.advance()
906
	case token.LBRACKET:
907
		posting.Type = ast.PostingVirtualBalanced
908
		p.advance()
909
	}
910
911
	// optional status, inside of brackets, '(* account)'
912
	if p.got(token.STAR) || p.got(token.BANG) {
913
		posting.Status = p.parseStatus()
914
	}
915
916
	// validate, must be account text
917
	if p.cur.Type != token.TEXT {
918
		p.errorf("expected account name, got %s", p.cur.Type)
919
		p.syncToNextline()
920
		return nil
921
	}
922
923
	posting.Account = p.parseAccount()
924
925
	// consume closing bracket
926
	switch p.cur.Type {
927
	case token.RPAREN:
928
		p.advance()
929
	case token.RBRACKET:
930
		p.advance()
931
	}
932
933
	// optional amount - after two spaces
934
	if p.got(token.WHITESPACE) {
935
		p.skipWhitespace()
936
		if p.isAmountStart() || p.got(token.STAR) {
937
			posting.Amount = p.parseAmountWithOptExpr()
938
		}
939
	}
940
941
	// optional cost '@' or '@@'
942
	if p.got(token.WHITESPACE) {
943
		p.skipWhitespace()
944
	}
945
	if p.got(token.AT) || p.got(token.ATAT) {
946
		posting.Cost = p.parseCost()
947
	}
948
949
	// optional balance assertion
950
	if p.got(token.WHITESPACE) {
951
		p.skipWhitespace()
952
	}
953
	if p.got(token.EQ) || p.got(token.EQEQ) || p.got(token.EQEQEQ) {
954
		posting.Balance = p.parseBalanceAssertion()
955
	}
956
957
	posting.Comment = p.parseOptInlineComment()
958
	p.expectNewline()
959
960
	// continuation comments
961
	for p.got(token.INDENT) && p.willGet(token.SEMICOLON) {
962
		p.advance()
963
		c := p.parseComment()
964
		posting.Comments = append(posting.Comments, *c)
965
	}
966
967
	posting.Span = p.span(s)
968
	return posting
969
}
970
971
func (p *Parser) parseCost() *ast.Cost {
972
	s := p.cur.Span
973
	isTotal := p.got(token.ATAT)
974
	p.advance() // consume '@' '@@'
975
	p.skipWhitespace()
976
	return &ast.Cost{
977
		IsTotal: isTotal,
978
		Amount:  *p.parseAmount(),
979
		Span:    p.span(s),
980
	}
981
}
982
983
func (p *Parser) parseBalanceAssertion() *ast.BalanceAssertion {
984
	s := p.cur.Span
985
986
	ba := &ast.BalanceAssertion{}
987
	switch p.cur.Type {
988
	case token.EQ: // basic assertion
989
	case token.EQEQ:
990
		ba.IsStrict = true
991
	case token.EQEQEQ:
992
		ba.IsStrict = true
993
		ba.IsInclusive = true
994
	}
995
	p.advance()
996
	p.skipWhitespace()
997
998
	ba.Amount = *p.parseAmount()
999
	p.skipWhitespace()
1000
	if p.got(token.AT) || p.got(token.ATAT) {
1001
		c := p.parseCost()
1002
		ba.Cost = c
1003
	}
1004
	ba.Span = p.span(s)
1005
	return ba
1006
}
1007
1008
func (p *Parser) readAccountSegment() (ast.SubAccount, bool) {
1009
	switch p.cur.Type {
1010
	case token.TEXT:
1011
		sub := ast.SubAccount{Name: p.cur.Literal, Span: p.cur.Span}
1012
		p.advance()
1013
1014
		// handle multi work segment, e.g: "credit card"
1015
		if p.got(token.WHITESPACE) && p.willGet(token.TEXT) && len(p.peek.Literal) > 0 && p.peek.Literal[0] != '(' {
1016
			sub.Name += " "
1017
			p.advance()
1018
			sub.Name += p.cur.Literal
1019
			p.advance()
1020
		}
1021
		return sub, true
1022
1023
	case token.COMMODITYMARK:
1024
		sub := ast.SubAccount{Name: p.cur.Literal, Span: p.cur.Span}
1025
		p.advance()
1026
		// merge "EUR" + "-HRK" to "EUR-HRK"
1027
		for p.got(token.TEXT) {
1028
			sub.Name += p.cur.Literal
1029
			p.advance()
1030
		}
1031
		return sub, true
1032
1033
	default:
1034
		return ast.SubAccount{}, false
1035
	}
1036
}
1037
1038
func (p *Parser) parseAccount() ast.Account {
1039
	s := p.cur.Span
1040
	acc := ast.Account{}
1041
1042
	sub, ok := p.readAccountSegment()
1043
	if !ok {
1044
		p.errorf("expected account, got %s", p.cur.Type)
1045
		return ast.Account{}
1046
	}
1047
	acc.Name = append(acc.Name, sub)
1048
1049
	for p.got(token.COLON) {
1050
		p.advance()
1051
		sub, ok := p.readAccountSegment()
1052
		if !ok {
1053
			break
1054
		}
1055
		acc.Name = append(acc.Name, sub)
1056
	}
1057
1058
	acc.Span = p.span(s)
1059
	return acc
1060
}
1061
1062
func (p *Parser) parseDate() ast.Date {
1063
	s := p.cur.Span
1064
	tok, ok := p.expect(token.DATE)
1065
	if !ok {
1066
		return ast.Date{Span: p.span(s)}
1067
	}
1068
1069
	sep := byte(0)
1070
	lit := tok.Literal
1071
	for i := 0; i < len(lit); i++ {
1072
		if lit[i] == '/' || lit[i] == '-' || lit[i] == '.' {
1073
			sep = lit[i]
1074
			break
1075
		}
1076
	}
1077
	if sep == 0 {
1078
		p.errorf("invalid date format: %q", lit)
1079
		return ast.Date{Span: p.span(s)}
1080
	}
1081
1082
	parts := strings.Split(lit, string(sep))
1083
1084
	// M/D or MM/DD (year inferred)
1085
	if len(parts) == 2 {
1086
		month, err := strconv.Atoi(parts[0])
1087
		day, err2 := strconv.Atoi(parts[1])
1088
		if err != nil || err2 != nil {
1089
			p.errorf("invalid date literal: %q", lit)
1090
			return ast.Date{Span: p.span(s)}
1091
		}
1092
		if month < 1 || month > 12 {
1093
			p.errorf("invalid month %d in %q", month, lit)
1094
			return ast.Date{Span: p.span(s)}
1095
		}
1096
		if day < 1 || day > 31 {
1097
			p.errorf("invalid day %d in %q", day, lit)
1098
			return ast.Date{Span: p.span(s)}
1099
		}
1100
		return ast.Date{Month: month, Day: day, Sep: sep, Span: p.span(s)}
1101
	}
1102
1103
	if len(parts) != 3 {
1104
		p.errorf("invalid date format: %q", lit)
1105
		return ast.Date{Span: p.span(s)}
1106
	}
1107
1108
	year, err := strconv.Atoi(parts[0])
1109
	month, err2 := strconv.Atoi(parts[1])
1110
	day, err3 := strconv.Atoi(parts[2])
1111
	if err != nil || err2 != nil || err3 != nil {
1112
		p.errorf("invalid date literal: %q", lit)
1113
		return ast.Date{Span: p.span(s)}
1114
	}
1115
	if month < 1 || month > 12 {
1116
		p.errorf("invalid month %d in %q", month, lit)
1117
		return ast.Date{Span: p.span(s)}
1118
	}
1119
	if day < 1 || day > 31 {
1120
		p.errorf("invalid day %d in %q", day, lit)
1121
		return ast.Date{Span: p.span(s)}
1122
	}
1123
1124
	return ast.Date{
1125
		Year:  year,
1126
		Month: month,
1127
		Day:   day,
1128
		Sep:   sep,
1129
		Span:  p.span(s),
1130
	}
1131
}
1132
1133
func (p *Parser) parseOptInlineComment() *ast.Comment {
1134
	p.skipWhitespace() // todo:
1135
	if p.cur.Type != token.SEMICOLON {
1136
		return nil
1137
	}
1138
1139
	s := p.cur.Span
1140
	marker := p.cur.Literal[0]
1141
	p.advance() // consume marker
1142
	p.skipWhitespace()
1143
1144
	text := ""
1145
	if p.got(token.TEXT) {
1146
		text = p.cur.Literal
1147
		p.advance()
1148
	}
1149
1150
	return &ast.Comment{
1151
		Marker: marker,
1152
		Text:   text,
1153
		Span:   p.span(s),
1154
	}
1155
}
1156
1157
func (p *Parser) parseOptPeriodicDescription() string {
1158
	if p.cur.Type != token.WHITESPACE || len(p.cur.Literal) < 2 {
1159
		return ""
1160
	}
1161
1162
	p.skipWhitespace()
1163
1164
	if p.cur.Type != token.TEXT {
1165
		return ""
1166
	}
1167
1168
	return p.parseDescription()
1169
}
1170
1171
func (p *Parser) parseDescription() string {
1172
	var desc strings.Builder
1173
	for p.got(token.TEXT) || (p.got(token.WHITESPACE) && p.willGet(token.TEXT)) {
1174
		_, _ = desc.WriteString(p.cur.Literal)
1175
		p.advance()
1176
	}
1177
	return desc.String()
1178
}
1179
1180
func (p *Parser) parseDirectiveExpr() string {
1181
	var b strings.Builder
1182
	for p.cur.Type != token.NEWLINE && p.cur.Type != token.EOF && p.cur.Type != token.SEMICOLON {
1183
		_, _ = b.WriteString(p.cur.Literal)
1184
		p.advance()
1185
	}
1186
	return b.String()
1187
}
1188
1189
func (p *Parser) parseQuantityInto(amt *ast.Amount) {
1190
	if p.cur.Type != token.INT && p.cur.Type != token.DECIMAL && p.cur.Type != token.TEXT {
1191
		p.errorf("expected quantity, got %s", p.cur.Type)
1192
		return
1193
	}
1194
1195
	lit := p.cur.Literal
1196
	p.advance()
1197
1198
	// detect format metadata before normalizing
1199
	amt.QuantityFmt = detectFormat(lit)
1200
1201
	// normalize for decimal.NewFromString
1202
	// remove thousands separators, replace decimal mark with '.'
1203
	normalized := normalizeLiteral(lit, amt.QuantityFmt.Thousands, amt.QuantityFmt.Decimal)
1204
1205
	q, err := decimal.FromString(normalized)
1206
	if err != nil {
1207
		p.errorf("invalid quantity %q: %v", lit, err)
1208
		return
1209
	}
1210
1211
	if amt.IsNegative {
1212
		q = q.Neg()
1213
	}
1214
	amt.Quantity = q
1215
}
1216
1217
func (p *Parser) parseBlankLine() *ast.BlankLine {
1218
	s := p.cur.Span
1219
	p.expectNewline()
1220
	return &ast.BlankLine{Span: s}
1221
}
1222
1223
func (p *Parser) expectNewline() {
1224
	if p.got(token.NEWLINE) || p.got(token.EOF) {
1225
		if p.got(token.NEWLINE) {
1226
			p.advance()
1227
		}
1228
		return
1229
	}
1230
	p.errorf("expected %s, got %s", token.NEWLINE, p.cur.Type)
1231
}
1232
1233
func (p *Parser) advance() token.Token {
1234
	prev := p.cur
1235
	p.cur = p.peek
1236
	p.peek = p.lexer.Next()
1237
	return prev
1238
}
1239
1240
func (p *Parser) got(kind token.Type) bool     { return p.cur.Type == kind }
1241
func (p *Parser) willGet(kind token.Type) bool { return p.peek.Type == kind }
1242
1243
func (p *Parser) expect(kind token.Type) (token.Token, bool) {
1244
	if p.got(kind) {
1245
		return p.advance(), true
1246
	}
1247
	p.errorf("expected %s, got %s", kind, p.cur.Type)
1248
	return p.cur, false
1249
}
1250
1251
func (p *Parser) errorf(format string, args ...any) {
1252
	p.errors = append(p.errors, &ast.ParseError{
1253
		Span:    p.cur.Span,
1254
		Message: fmt.Sprintf(format, args...),
1255
	})
1256
}
1257
1258
func (p *Parser) sync() {
1259
	for {
1260
		switch p.cur.Type {
1261
		case token.EOF:
1262
			return
1263
		case token.NEWLINE:
1264
			p.advance()
1265
			switch p.cur.Type {
1266
			case token.DATE, token.ACCOUNT, token.COMMODITY,
1267
				token.INCLUDE, token.ALIAS, token.PAYEE,
1268
				token.TAG, token.YEAR, token.D, token.P,
1269
				token.APPLY, token.END, token.COMMENTKW,
1270
				token.DECIMALMARK, token.TILDE, token.N, token.EQ:
1271
				return
1272
			}
1273
		default:
1274
			p.advance()
1275
		}
1276
	}
1277
}
1278
1279
func (p *Parser) syncToNextline() {
1280
	for p.cur.Type != token.NEWLINE && p.cur.Type != token.EOF {
1281
		p.advance()
1282
	}
1283
	if p.got(token.NEWLINE) {
1284
		p.advance()
1285
	}
1286
}
1287
1288
func (p *Parser) skipWhitespace() {
1289
	for p.got(token.WHITESPACE) {
1290
		p.advance()
1291
	}
1292
}
1293
1294
func (p *Parser) span(s token.Span) token.Span {
1295
	return token.Span{Start: s.Start, End: p.cur.Span.Start}
1296
}
1297
1298
func normalizeLiteral(lit string, thousands, decimal byte) string {
1299
	var b strings.Builder
1300
	for _, ch := range []byte(lit) {
1301
		if thousands != 0 && ch == thousands {
1302
			continue // skip thousands separator
1303
		}
1304
		if ch == decimal {
1305
			b.WriteByte('.')
1306
		} else {
1307
			b.WriteByte(ch)
1308
		}
1309
	}
1310
	return b.String()
1311
}
1312
1313
func detectFormat(lit string) ast.QuantityFormat {
1314
	var separators []int
1315
	for i, ch := range []byte(lit) {
1316
		if ch == '.' || ch == ',' || ch == ' ' || ch == '_' || ch == '\'' {
1317
			separators = append(separators, i)
1318
		}
1319
	}
1320
1321
	if len(separators) == 0 {
1322
		return ast.QuantityFormat{Decimal: '.', Thousands: 0, Precision: 0}
1323
	}
1324
1325
	var decimal byte
1326
	thousands := byte(0)
1327
	precision := 0
1328
1329
	if len(separators) == 1 {
1330
		pos := separators[0]
1331
		sepChar := lit[pos]
1332
		if sepChar == ' ' || sepChar == '_' || sepChar == '\'' {
1333
			thousands = sepChar
1334
			decimal = '.' // default
1335
			precision = 0
1336
		} else {
1337
			decimal = sepChar
1338
			precision = len(lit) - pos - 1
1339
		}
1340
	} else {
1341
		last := separators[len(separators)-1]
1342
		decimal = lit[last]
1343
		thousands = lit[separators[0]]
1344
		precision = len(lit) - last - 1
1345
	}
1346
1347
	return ast.QuantityFormat{
1348
		Decimal:   decimal,
1349
		Thousands: thousands,
1350
		Precision: precision,
1351
	}
1352
}
1353
1354
func parseSimpleDate(s string) ast.Date {
1355
	if len(s) < 8 {
1356
		return ast.Date{}
1357
	}
1358
	sep := byte('-')
1359
	if strings.Contains(s, "/") {
1360
		sep = byte('/')
1361
	} else if strings.Contains(s, ".") {
1362
		sep = byte('.')
1363
	}
1364
	parts := strings.Split(s, string(sep))
1365
	if len(parts) != 3 {
1366
		return ast.Date{}
1367
	}
1368
	year, _ := strconv.Atoi(parts[0])
1369
	month, _ := strconv.Atoi(parts[1])
1370
	day, _ := strconv.Atoi(parts[2])
1371
	return ast.Date{Year: year, Month: month, Day: day, Sep: sep}
1372
}