all repos

clerk @ 540e892b4c90d34a76386b17ffab9266ae1a0485

missing tooling for ledger/hledger

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

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