all repos

clerk @ 86eedb486d43a3782f13c18fc9302302f1bd28e7

missing tooling for ledger/hledger

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

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