diff --git a/ledger/file.go b/ledger/file.go index 0091cfe..cda1ee2 100644 --- a/ledger/file.go +++ b/ledger/file.go @@ -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 -} diff --git a/ledger/file_test.go b/ledger/file_test.go index 3aed830..59694a1 100644 --- a/ledger/file_test.go +++ b/ledger/file_test.go @@ -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) - } - }) - } -} diff --git a/ledger/transaction.go b/ledger/transaction.go new file mode 100644 index 0000000..c2385fd --- /dev/null +++ b/ledger/transaction.go @@ -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 +} diff --git a/ledger/transaction_test.go b/ledger/transaction_test.go new file mode 100644 index 0000000..39cde87 --- /dev/null +++ b/ledger/transaction_test.go @@ -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) + } + }) + } +}