all repos

clerk @ a91ce69

missing tooling for ledger/hledger

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

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