break out for take 2

main
Bel LaPointe 2023-10-24 11:25:44 -06:00
parent 54febfef08
commit d8724bb27f
4 changed files with 335 additions and 325 deletions

View File

@ -1,12 +1,7 @@
package ledger
import (
"bytes"
"fmt"
"io"
"os"
"strconv"
"strings"
)
type File string
@ -59,262 +54,3 @@ func (file File) Deltas(like ...Like) ([]Delta, error) {
}
return result, nil
}
type transaction struct {
date string
description string
payee string
recipients []transactionRecipient
}
func (t transaction) empty() bool {
return fmt.Sprint(t) == fmt.Sprint(transaction{})
}
type transactionRecipient struct {
name string
value float64
currency string
}
func (t transactionRecipient) empty() bool {
return t == (transactionRecipient{})
}
func (file File) transactions() ([]transaction, error) {
f, err := os.Open(string(file))
if err != nil {
return nil, err
}
defer f.Close()
var r io.Reader = f
result := make([]transaction, 0)
for {
newr, one, err := readTransaction(r)
if !one.empty() {
result = append(result, one)
}
if err == io.EOF {
return result, nil
}
if err != nil {
return result, err
}
r = newr
}
}
func readTransaction(r io.Reader) (io.Reader, transaction, error) {
result := transaction{}
var err error
if r, err = readTransactionLeadingWhitespace(r); err != nil {
return r, transaction{}, err
}
if result.date, err = readTransactionDate(r); err != nil {
if err == io.EOF {
return r, transaction{}, err
}
return nil, result, fmt.Errorf("did not find transaction date: %w", err)
}
if result.description, err = readTransactionDescription(r); err != nil {
return nil, result, fmt.Errorf("did not find transaction description: %w", err)
}
for {
newR, recipient, err := readTransactionRecipient(r)
r = newR
if !recipient.empty() {
if recipient.currency == "" {
result.payee = recipient.name
} else {
result.recipients = append(result.recipients, recipient)
}
}
if err != nil || recipient.empty() {
return r, result, err
}
}
}
// from "\s*.* read \s*"
func readTransactionLeadingWhitespace(r io.Reader) (io.Reader, error) {
for {
b, err := readOne(r)
if err != nil {
return r, err
}
if b != 0 && !isSpaceByte(b) {
return io.MultiReader(bytes.NewReader([]byte{b}), r), nil
}
}
}
// from "20yy-mm-dd.* read "20yy-mm-dd"
func readTransactionDate(r io.Reader) (string, error) {
startOfDate := []byte{}
for len(startOfDate) == 0 {
b, err := readOne(r)
if err != nil {
return "", err
}
if '0' <= b && b <= '9' {
startOfDate = append(startOfDate, b)
}
}
restOfDate := make([]byte, len("2006-01-02")-len(startOfDate))
if _, err := r.Read(restOfDate); err != nil {
return "", err
}
return fmt.Sprintf("%s%s", startOfDate, restOfDate), nil
}
// from "\s*.*\n.*" read \s*.*\n.*"
func readTransactionDescription(r io.Reader) (string, error) {
result := make([]byte, 0, 16)
for {
b, err := readOne(r)
if err != nil {
return "", err
}
if b == '\n' {
break
}
result = append(result, b)
}
return strings.TrimSpace(string(result)), nil
}
// from "\s+.*\n?.*" read "\s+.*\n?"
func readTransactionRecipient(r io.Reader) (io.Reader, transactionRecipient, error) {
if b, err := readOne(r); err != nil {
return r, transactionRecipient{}, err
} else if !isSpaceByte(b) {
return io.MultiReader(bytes.NewReader([]byte{b}), r), transactionRecipient{}, nil
}
r, err := readTransactionLeadingWhitespace(r)
if err != nil {
return r, transactionRecipient{}, err
}
result := transactionRecipient{}
for {
b, err := readOne(r)
if err != nil {
if result.empty() {
return nil, result, err
}
return nil, result, fmt.Errorf("failed to read transaction recipient name after skipping whitespace: %w", err)
} else if !isSpaceByte(b) {
result.name += string([]byte{b})
continue
}
r = io.MultiReader(bytes.NewReader([]byte{b}), r)
break
}
if result.name == "" {
return nil, result, fmt.Errorf("did not parse any name for transaction recipient")
}
// read "NAME:NAME", now "(\s+|\n).*"
for {
b, err := readOne(r)
if err != nil {
return r, result, err
}
if isSpaceByte(b) && b != '\n' {
continue
}
r = io.MultiReader(bytes.NewReader([]byte{b}), r)
break
}
// read "\s+", now "(\n|\$1.00)"
if b, err := readOne(r); err != nil {
return r, result, err
} else if isSpaceByte(b) {
return r, result, err
} else if newr, value, currency, err := readTransactionValueCurrency(io.MultiReader(bytes.NewReader([]byte{b}), r)); err != nil {
return r, result, fmt.Errorf("failed to read transaction recipient's value and currency: %w", err)
} else {
r = newr
result.value = value
result.currency = currency
}
return r, result, err
}
func readOne(r io.Reader) (byte, error) {
var firstByte [1]byte
_, err := r.Read(firstByte[:])
return firstByte[0], err
}
func isSpaceByte(b byte) bool {
return len(bytes.TrimSpace([]byte{b})) == 0
}
func isNumericByte(b byte) bool {
return '0' <= b && b <= '9' || b == '.' || b == '-'
}
// from "[^\n]*\n?.*" read "[^\n]*\n?"
func readTransactionValueCurrency(r io.Reader) (io.Reader, float64, string, error) {
restOfLine := make([]byte, 0, 16)
for {
b, err := readOne(r)
if b == '\n' || err == io.EOF {
break
}
if err != nil {
return r, 0, "", err
}
restOfLine = append(restOfLine, b)
}
restOfLine = bytes.TrimSpace(restOfLine)
if len(restOfLine) == 0 {
return r, 0, "", nil
}
firstNonNumericIdx := -1
firstNumericIdx := -1
for i := range restOfLine {
if !isNumericByte(restOfLine[i]) && firstNonNumericIdx == -1 {
firstNonNumericIdx = i
}
if isNumericByte(restOfLine[i]) && firstNumericIdx == -1 {
firstNumericIdx = i
}
}
if firstNonNumericIdx == -1 || firstNumericIdx == -1 {
return r, 0, "", fmt.Errorf("needed numeric and non-numeric bytes in %q", restOfLine)
}
var valueS, currency string
if firstNonNumericIdx < firstNumericIdx {
currency = string(restOfLine[firstNonNumericIdx:firstNumericIdx])
valueS = string(restOfLine[firstNumericIdx:])
} else {
valueS = string(restOfLine[firstNumericIdx:firstNonNumericIdx])
currency = string(restOfLine[firstNonNumericIdx:])
}
valueS = strings.TrimSpace(valueS)
currency = strings.TrimSpace(currency)
value, err := strconv.ParseFloat(valueS, 64)
if err != nil {
return r, 0, "", fmt.Errorf("value %q is not a float: %w", valueS, err)
}
return r, value, currency, nil
}

View File

@ -1,10 +1,7 @@
package ledger
import (
"fmt"
"io"
"path/filepath"
"strings"
"testing"
)
@ -97,61 +94,3 @@ func TestFileDeltas(t *testing.T) {
})
}
}
func TestReadTransaction(t *testing.T) {
cases := map[string]struct {
input string
want transaction
err error
}{
"empty": {
input: "",
want: transaction{},
err: io.EOF,
},
"white space": {
input: " ",
want: transaction{},
err: io.EOF,
},
"verbose": {
input: `
2003-04-05 Reasoning here
A:B $1.00
C:D $-1.00
`,
want: transaction{
date: "2003-04-05",
description: "Reasoning here",
payee: "",
recipients: []transactionRecipient{
{
name: "A:B",
value: 1.0,
currency: "$",
},
{
name: "C:D",
value: -1.0,
currency: "$",
},
},
},
err: io.EOF,
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
_, got, err := readTransaction(strings.NewReader(c.input))
if err != c.err {
t.Error(err)
}
if fmt.Sprintf("%+v", got) != fmt.Sprintf("%+v", c.want) {
t.Errorf("want\n\t%+v, got\n\t%+v", c.want, got)
}
})
}
}

269
ledger/transaction.go Normal file
View File

@ -0,0 +1,269 @@
package ledger
import (
"bytes"
"fmt"
"io"
"os"
"strconv"
"strings"
)
type transaction struct {
date string
description string
payee string
recipients []transactionRecipient
}
func (t transaction) empty() bool {
return fmt.Sprint(t) == fmt.Sprint(transaction{})
}
type transactionRecipient struct {
name string
value float64
currency string
}
func (t transactionRecipient) empty() bool {
return t == (transactionRecipient{})
}
func (file File) transactions() ([]transaction, error) {
f, err := os.Open(string(file))
if err != nil {
return nil, err
}
defer f.Close()
var r io.Reader = f
result := make([]transaction, 0)
for {
newr, one, err := readTransaction(r)
if !one.empty() {
result = append(result, one)
}
if err == io.EOF {
return result, nil
}
if err != nil {
return result, err
}
r = newr
}
}
func readTransaction(r io.Reader) (io.Reader, transaction, error) {
result := transaction{}
var err error
if r, err = readTransactionLeadingWhitespace(r); err != nil {
return r, transaction{}, err
}
if result.date, err = readTransactionDate(r); err != nil {
if err == io.EOF {
return r, transaction{}, err
}
return nil, result, fmt.Errorf("did not find transaction date: %w", err)
}
if result.description, err = readTransactionDescription(r); err != nil {
return nil, result, fmt.Errorf("did not find transaction description: %w", err)
}
for {
newR, recipient, err := readTransactionRecipient(r)
r = newR
if !recipient.empty() {
if recipient.currency == "" {
result.payee = recipient.name
} else {
result.recipients = append(result.recipients, recipient)
}
}
if err != nil || recipient.empty() {
return r, result, err
}
}
}
// from "\s*.* read \s*"
func readTransactionLeadingWhitespace(r io.Reader) (io.Reader, error) {
for {
b, err := readOne(r)
if err != nil {
return r, err
}
if b != 0 && !isSpaceByte(b) {
return io.MultiReader(bytes.NewReader([]byte{b}), r), nil
}
}
}
// from "20yy-mm-dd.* read "20yy-mm-dd"
func readTransactionDate(r io.Reader) (string, error) {
startOfDate := []byte{}
for len(startOfDate) == 0 {
b, err := readOne(r)
if err != nil {
return "", err
}
if '0' <= b && b <= '9' {
startOfDate = append(startOfDate, b)
}
}
restOfDate := make([]byte, len("2006-01-02")-len(startOfDate))
if _, err := r.Read(restOfDate); err != nil {
return "", err
}
return fmt.Sprintf("%s%s", startOfDate, restOfDate), nil
}
// from "\s*.*\n.*" read \s*.*\n.*"
func readTransactionDescription(r io.Reader) (string, error) {
result := make([]byte, 0, 16)
for {
b, err := readOne(r)
if err != nil {
return "", err
}
if b == '\n' {
break
}
result = append(result, b)
}
return strings.TrimSpace(string(result)), nil
}
// from "\s+.*\n?.*" read "\s+.*\n?"
func readTransactionRecipient(r io.Reader) (io.Reader, transactionRecipient, error) {
if b, err := readOne(r); err != nil {
return r, transactionRecipient{}, err
} else if !isSpaceByte(b) {
return io.MultiReader(bytes.NewReader([]byte{b}), r), transactionRecipient{}, nil
}
r, err := readTransactionLeadingWhitespace(r)
if err != nil {
return r, transactionRecipient{}, err
}
result := transactionRecipient{}
for {
b, err := readOne(r)
if err != nil {
if result.empty() {
return nil, result, err
}
return nil, result, fmt.Errorf("failed to read transaction recipient name after skipping whitespace: %w", err)
} else if !isSpaceByte(b) {
result.name += string([]byte{b})
continue
}
r = io.MultiReader(bytes.NewReader([]byte{b}), r)
break
}
if result.name == "" {
return nil, result, fmt.Errorf("did not parse any name for transaction recipient")
}
// read "NAME:NAME", now "(\s+|\n).*"
for {
b, err := readOne(r)
if err != nil {
return r, result, err
}
if isSpaceByte(b) && b != '\n' {
continue
}
r = io.MultiReader(bytes.NewReader([]byte{b}), r)
break
}
// read "\s+", now "(\n|\$1.00)"
if b, err := readOne(r); err != nil {
return r, result, err
} else if isSpaceByte(b) {
return r, result, err
} else if newr, value, currency, err := readTransactionValueCurrency(io.MultiReader(bytes.NewReader([]byte{b}), r)); err != nil {
return r, result, fmt.Errorf("failed to read transaction recipient's value and currency: %w", err)
} else {
r = newr
result.value = value
result.currency = currency
}
return r, result, err
}
func readOne(r io.Reader) (byte, error) {
var firstByte [1]byte
_, err := r.Read(firstByte[:])
return firstByte[0], err
}
func isSpaceByte(b byte) bool {
return len(bytes.TrimSpace([]byte{b})) == 0
}
func isNumericByte(b byte) bool {
return '0' <= b && b <= '9' || b == '.' || b == '-'
}
// from "[^\n]*\n?.*" read "[^\n]*\n?"
func readTransactionValueCurrency(r io.Reader) (io.Reader, float64, string, error) {
restOfLine := make([]byte, 0, 16)
for {
b, err := readOne(r)
if b == '\n' || err == io.EOF {
break
}
if err != nil {
return r, 0, "", err
}
restOfLine = append(restOfLine, b)
}
restOfLine = bytes.TrimSpace(restOfLine)
if len(restOfLine) == 0 {
return r, 0, "", nil
}
firstNonNumericIdx := -1
firstNumericIdx := -1
for i := range restOfLine {
if !isNumericByte(restOfLine[i]) && firstNonNumericIdx == -1 {
firstNonNumericIdx = i
}
if isNumericByte(restOfLine[i]) && firstNumericIdx == -1 {
firstNumericIdx = i
}
}
if firstNonNumericIdx == -1 || firstNumericIdx == -1 {
return r, 0, "", fmt.Errorf("needed numeric and non-numeric bytes in %q", restOfLine)
}
var valueS, currency string
if firstNonNumericIdx < firstNumericIdx {
currency = string(restOfLine[firstNonNumericIdx:firstNumericIdx])
valueS = string(restOfLine[firstNumericIdx:])
} else {
valueS = string(restOfLine[firstNumericIdx:firstNonNumericIdx])
currency = string(restOfLine[firstNonNumericIdx:])
}
valueS = strings.TrimSpace(valueS)
currency = strings.TrimSpace(currency)
value, err := strconv.ParseFloat(valueS, 64)
if err != nil {
return r, 0, "", fmt.Errorf("value %q is not a float: %w", valueS, err)
}
return r, value, currency, nil
}

View File

@ -0,0 +1,66 @@
package ledger
import (
"fmt"
"io"
"strings"
"testing"
)
func TestReadTransaction(t *testing.T) {
cases := map[string]struct {
input string
want transaction
err error
}{
"empty": {
input: "",
want: transaction{},
err: io.EOF,
},
"white space": {
input: " ",
want: transaction{},
err: io.EOF,
},
"verbose": {
input: `
2003-04-05 Reasoning here
A:B $1.00
C:D $-1.00
`,
want: transaction{
date: "2003-04-05",
description: "Reasoning here",
payee: "",
recipients: []transactionRecipient{
{
name: "A:B",
value: 1.0,
currency: "$",
},
{
name: "C:D",
value: -1.0,
currency: "$",
},
},
},
err: io.EOF,
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
_, got, err := readTransaction(strings.NewReader(c.input))
if err != c.err {
t.Error(err)
}
if fmt.Sprintf("%+v", got) != fmt.Sprintf("%+v", c.want) {
t.Errorf("want\n\t%+v, got\n\t%+v", c.want, got)
}
})
}
}