diff --git a/parse.go b/parse.go index 65f7697b..ea93c246 100644 --- a/parse.go +++ b/parse.go @@ -65,7 +65,6 @@ func ParseLedgerAsync(ledgerReader io.Reader) (c chan *Transaction, e chan error var accountToAmountSpace = regexp.MustCompile(" {2,}|\t+") func parseLedger(ledgerReader io.Reader, callback func(t *Transaction, err error) (stop bool)) { - var trans *Transaction scanner := bufio.NewScanner(ledgerReader) var line string var filename string @@ -98,32 +97,118 @@ func parseLedger(ledgerReader io.Reader, callback func(t *Transaction, err error } } + // Skip empty lines if len(trimmedLine) == 0 { - if trans != nil { - transErr := balanceTransaction(trans) - if transErr != nil { - errorMsg("Unable to balance transaction, " + transErr.Error()) - } - trans.Comments = comments - callback(trans, nil) - comments = nil - trans = nil + continue + } + + lineSplit := strings.SplitN(trimmedLine, " ", 2) + if len(lineSplit) != 2 { + if errorMsg("Unable to parse payee line: " + line) { + return } - } else if trans == nil { - lineSplit := strings.SplitN(trimmedLine, " ", 2) - if len(lineSplit) != 2 { - if errorMsg("Unable to parse payee line: " + line) { + continue + } + commandDirective := lineSplit[0] + switch commandDirective { + case "account": + _, lines, _ := parseAccount(scanner) + lineCount += lines + default: + trans, lines, transErr := parseTransaction(scanner) + lineCount += lines + if transErr != nil { + if errorMsg(fmt.Errorf("Unable to parse transaction: %w", transErr).Error()) { return } continue } - dateString := lineSplit[0] - transDate, dateErr := date.Parse(dateString) - if dateErr != nil { - errorMsg("Unable to parse date: " + dateString) + trans.Comments = append(comments, trans.Comments...) + callback(trans, nil) + comments = nil + } + } +} + +func parseAccount(scanner *bufio.Scanner) (accountName string, lines int, err error) { + line := scanner.Text() + // remove heading and tailing space from the line + trimmedLine := strings.Trim(line, whitespace) + + lineSplit := strings.SplitN(trimmedLine, " ", 2) + if len(lineSplit) != 2 { + err = fmt.Errorf("Unable to parse account line: %s", line) + return + } + accountName = lineSplit[1] + + for scanner.Scan() { + // Read until blank line (ignore all sub-directives) + line = scanner.Text() + // remove heading and tailing space from the line + trimmedLine = strings.Trim(line, whitespace) + lines++ + + // skip comments + if commentIdx := strings.Index(trimmedLine, ";"); commentIdx >= 0 { + trimmedLine = trimmedLine[:commentIdx] + } + + // continue slurping up sub-directives until empty line + if len(trimmedLine) == 0 { + return + } + } + + return +} + +func parseTransaction(scanner *bufio.Scanner) (trans *Transaction, lines int, err error) { + var comments []string + + line := scanner.Text() + trimmedLine := strings.Trim(line, whitespace) + + // Parse Date-Payee line + lineSplit := strings.SplitN(trimmedLine, " ", 2) + if len(lineSplit) != 2 { + err = fmt.Errorf("Unable to parse payee line: %s", line) + return + } + dateString := lineSplit[0] + transDate, dateErr := date.Parse(dateString) + if dateErr != nil { + err = fmt.Errorf("Unable to parse date: %s", dateString) + return + } + payeeString := lineSplit[1] + trans = &Transaction{Payee: payeeString, Date: transDate} + + for scanner.Scan() { + line = scanner.Text() + // remove heading and tailing space from the line + trimmedLine = strings.Trim(line, whitespace) + lines++ + + // handle comments + if commentIdx := strings.Index(trimmedLine, ";"); commentIdx >= 0 { + comments = append(comments, trimmedLine[commentIdx:]) + trimmedLine = trimmedLine[:commentIdx] + if len(trimmedLine) == 0 { + continue + } + } + + if len(trimmedLine) == 0 { + if trans != nil { + transErr := balanceTransaction(trans) + if transErr != nil { + err = fmt.Errorf("Unable to balance transaction: %w", transErr) + return + } + trans.Comments = comments + return } - payeeString := lineSplit[1] - trans = &Transaction{Payee: payeeString, Date: transDate} } else { var accChange Account lineSplit := accountToAmountSpace.Split(trimmedLine, -1) @@ -150,11 +235,12 @@ func parseLedger(ledgerReader io.Reader, callback func(t *Transaction, err error if trans != nil { transErr := balanceTransaction(trans) if transErr != nil { - errorMsg("Unable to balance transaction, " + transErr.Error()) + err = fmt.Errorf("Unable to balance transaction: %w", transErr) + return } trans.Comments = comments - callback(trans, nil) } + return } func getBalance(balance string) (bool, *big.Rat) { @@ -176,7 +262,7 @@ func balanceTransaction(input *Transaction) error { for accIndex, accChange := range input.AccountChanges { if accChange.Balance == nil { if emptyFound { - return fmt.Errorf("more than one account change empty") + return fmt.Errorf("more than one account empty") } emptyAccIndex = accIndex emptyFound = true @@ -186,7 +272,7 @@ func balanceTransaction(input *Transaction) error { } if balance.Sign() != 0 { if !emptyFound { - return fmt.Errorf("no empty account change to place extra balance") + return fmt.Errorf("no empty account to place extra balance") } } if emptyFound { diff --git a/parse_test.go b/parse_test.go index 19ccd250..3ba0ce9b 100644 --- a/parse_test.go +++ b/parse_test.go @@ -3,12 +3,14 @@ package ledger import ( "bytes" "encoding/json" + "errors" "math/big" "testing" "time" ) type testCase struct { + name string data string transactions []*Transaction err error @@ -16,6 +18,7 @@ type testCase struct { var testCases = []testCase{ testCase{ + "simple", `1970/01/01 Payee Expense/test (123 * 3) Assets @@ -39,6 +42,71 @@ var testCases = []testCase{ nil, }, testCase{ + "unbalanced error", + `1970/01/01 Payee + Expense/test (123 * 3) + Assets 123 +`, + nil, + errors.New(":3: Unable to parse transaction: Unable to balance transaction: no empty account to place extra balance"), + }, + testCase{ + "multiple empty", + `1970/01/01 Payee + Expense/test (123 * 3) + Wallet + Assets 123 + Bank +`, + nil, + errors.New(":5: Unable to parse transaction: Unable to balance transaction: more than one account empty"), + }, + testCase{ + "multiple empty lines", + `1970/01/01 Payee + Expense/test (123 * 3) + Assets + + + +1970/01/01 Payee + Expense/test 123 + Assets +`, + []*Transaction{ + &Transaction{ + Payee: "Payee", + Date: time.Unix(0, 0).UTC(), + AccountChanges: []Account{ + Account{ + "Expense/test", + big.NewRat(369.0, 1), + }, + Account{ + "Assets", + big.NewRat(-369.0, 1), + }, + }, + }, + &Transaction{ + Payee: "Payee", + Date: time.Unix(0, 0).UTC(), + AccountChanges: []Account{ + Account{ + "Expense/test", + big.NewRat(123.0, 1), + }, + Account{ + "Assets", + big.NewRat(-123.0, 1), + }, + }, + }, + }, + nil, + }, + testCase{ + "accounts with spaces", `1970/01/02 Payee Expense:test 369.0 Assets @@ -101,6 +169,7 @@ var testCases = []testCase{ nil, }, testCase{ + "accounts with slashes", `1970-01-01 Payee Expense/another 5 Expense/test @@ -129,6 +198,7 @@ var testCases = []testCase{ nil, }, testCase{ + "comment inside transaction", `1970-01-01 Payee Expense/test 123 ; Expense/test 123 @@ -156,6 +226,7 @@ var testCases = []testCase{ nil, }, testCase{ + "multiple comments", `; comment 1970/01/01 Payee Expense/test 58 @@ -189,6 +260,7 @@ var testCases = []testCase{ nil, }, testCase{ + "header comment", `; comment 1970/01/01 Payee Expense/test 58 @@ -225,25 +297,120 @@ var testCases = []testCase{ }, nil, }, + testCase{ + "account skip", + `1970/01/01 Payee + Expense/test 123 + Assets + +account Expense/test + +account Assets + note bambam + payee junkjunk + +1970/01/01 Payee + Expense/test (123 * 2) + Assets +`, + []*Transaction{ + &Transaction{ + Payee: "Payee", + Date: time.Unix(0, 0).UTC(), + AccountChanges: []Account{ + Account{ + "Expense/test", + big.NewRat(123.0, 1), + }, + Account{ + "Assets", + big.NewRat(-123.0, 1), + }, + }, + }, + &Transaction{ + Payee: "Payee", + Date: time.Unix(0, 0).UTC(), + AccountChanges: []Account{ + Account{ + "Expense/test", + big.NewRat(246.0, 1), + }, + Account{ + "Assets", + big.NewRat(-246.0, 1), + }, + }, + }, + }, + nil, + }, + testCase{ + "multiple account skip", + `1970/01/01 Payee + Expense/test 123 + Assets + +account Banking +account Expense/test +account Assets + +1970/01/01 Payee + Expense/test (123 * 2) + Assets +`, + []*Transaction{ + &Transaction{ + Payee: "Payee", + Date: time.Unix(0, 0).UTC(), + AccountChanges: []Account{ + Account{ + "Expense/test", + big.NewRat(123.0, 1), + }, + Account{ + "Assets", + big.NewRat(-123.0, 1), + }, + }, + }, + &Transaction{ + Payee: "Payee", + Date: time.Unix(0, 0).UTC(), + AccountChanges: []Account{ + Account{ + "Expense/test", + big.NewRat(246.0, 1), + }, + Account{ + "Assets", + big.NewRat(-246.0, 1), + }, + }, + }, + }, + nil, + }, } func TestParseLedger(t *testing.T) { for _, tc := range testCases { b := bytes.NewBufferString(tc.data) transactions, err := ParseLedger(b) - if err != tc.err { + if (err != nil && tc.err == nil) || (err != nil && tc.err != nil && err.Error() != tc.err.Error()) { t.Errorf("Error: expected `%s`, got `%s`", tc.err, err) } exp, _ := json.Marshal(tc.transactions) got, _ := json.Marshal(transactions) if string(exp) != string(got) { - t.Errorf("Error: expected \n`%s`, \ngot \n`%s`", exp, got) + t.Errorf("Error(%s): expected \n`%s`, \ngot \n`%s`", tc.name, exp, got) } } } func BenchmarkParseLedger(b *testing.B) { tc := testCase{ + "benchmark", `1970/01/01 Payee Expense/test (123 * 3) Assets @@ -269,6 +436,6 @@ func BenchmarkParseLedger(b *testing.B) { data := bytes.NewBufferString(tc.data) for n := 0; n < b.N; n++ { - ParseLedger(data) + _, _ = ParseLedger(data) } }