beanbot/syntax/syntax.go

151 lines
2.7 KiB
Go

package syntax
import (
"errors"
"strconv"
"time"
)
var ErrMissingPayee = errors.New("missing payee")
var ErrUnknownAccount = errors.New("unknown account")
var ErrEmptyTransaction = errors.New("no postings defined")
type Config struct {
Currencies []string `yaml:"currencies"`
Accounts map[string]string `yaml:"accounts"`
DefaultCurrency string `yaml:"defaultCurrency"`
}
func (c *Config) hasCurrency(cur string) bool {
for _, v := range c.Currencies {
if v == cur {
return true
}
}
return false
}
func (c *Config) account(abbr string) (string, bool) {
if res, ok := c.Accounts[abbr]; ok {
return res, true
}
return "", false
}
type tokenReader struct {
tokens []string
cur int
}
func (r *tokenReader) HasNext() bool {
return r.cur < len(r.tokens)
}
func (r *tokenReader) Peek() string {
return r.tokens[r.cur]
}
func (r *tokenReader) Next() string {
res := r.tokens[r.cur]
r.cur++
return res
}
func Parse(tokens []string, cfg *Config) (*Transaction, error) {
r := &tokenReader{tokens: tokens}
txn := &Transaction{}
// optional transaction date
if r.HasNext() {
t, err := time.Parse("2006-01-02", r.Peek())
if err == nil {
txn.Date = t.Format("2006-01-02")
r.Next()
} else {
// default date: current date
txn.Date = time.Now().Format("2006-01-02")
}
}
// optional txn mark
if r.HasNext() {
if r.Peek() == "*" || r.Peek() == "!" {
txn.Flag = r.Next()
} else {
// default flag is * (complete txn)
txn.Flag = "*"
}
}
// mandatory payee
if !r.HasNext() {
return nil, ErrMissingPayee
}
txn.Payee = r.Next()
p, err := readPosting(r, cfg)
if err != nil {
// account does not exist, token is optional narration
if r.HasNext() {
txn.Narration = r.Next()
}
} else {
txn.Postings = append(txn.Postings, p)
}
// remaining postings
for r.HasNext() {
p, err := readPosting(r, cfg)
if err != nil {
return nil, err
}
txn.Postings = append(txn.Postings, p)
}
if len(txn.Postings) == 0 {
return nil, ErrEmptyTransaction
}
return txn, txn.Validate()
}
func readPosting(r *tokenReader, cfg *Config) (*Posting, error) {
p := &Posting{}
// mandatory account
if !r.HasNext() {
return nil, ErrUnknownAccount
}
account, ok := cfg.account(r.Peek())
if !ok {
return nil, ErrUnknownAccount
}
p.Account = account
r.Next()
// optional flag
if r.HasNext() && (r.Peek() == "!" || r.Peek() == "*") {
p.Flag = r.Next()
} else {
p.Flag = "*"
}
// optional amount
if r.HasNext() {
amount, err := strconv.ParseFloat(r.Peek(), 64)
if err == nil {
p.Amount = amount
r.Next()
}
}
// optional currency
if r.HasNext() && cfg.hasCurrency(r.Peek()) {
p.Currency = r.Next()
} else {
p.Currency = cfg.DefaultCurrency
}
return p, nil
}