all repos

clerk @ 1f3a17349e37c8189adb11b8d956acbf3b2080d1

missing tooling for ledger/hledger

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

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