package ledger import ( "bufio" "bytes" "fmt" "io" "os" "regexp" "strconv" "unicode" ) 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() r := bufio.NewReaderSize(f, 2048) result := make([]transaction, 0) for { one, err := readTransaction(r) if !one.empty() { result = append(result, one) } if err == io.EOF { return result, nil } if err != nil { return result, err } } } func readTransaction(r *bufio.Reader) (transaction, error) { result, err := _readTransaction(r) if err != nil { return result, err } if result.empty() { return result, nil } if result.payee == "" && len(result.recipients) < 2 { return result, fmt.Errorf("found a transaction with no payee and less than 2 recipeints: %+v", result) } if result.payee != "" && len(result.recipients) < 1 { return result, fmt.Errorf("found a transaction with payee but no recipeints: %+v", result) } return result, nil } func _readTransaction(r *bufio.Reader) (transaction, error) { readTransactionLeadingWhitespace(r) firstLine, err := readTransactionLine(r) if len(bytes.TrimSpace(firstLine)) == 0 { return transaction{}, err } dateDescriptionPattern := regexp.MustCompile(`^([0-9]+-[0-9]+-[0-9]+)\s+(.*)$`) dateDescriptionMatches := dateDescriptionPattern.FindAllSubmatch(firstLine, 4) if len(dateDescriptionMatches) != 1 { return transaction{}, fmt.Errorf("bad first line: %v matches: %q", len(dateDescriptionMatches), firstLine) } else if len(dateDescriptionMatches[0]) != 3 { return transaction{}, fmt.Errorf("bad first line: %v submatches: %q", len(dateDescriptionMatches[0]), firstLine) } result := transaction{ date: string(dateDescriptionMatches[0][1]), description: string(dateDescriptionMatches[0][2]), } for { name, value, currency, err := readTransactionAccount(r) if name != "" { if currency == "" { result.payee = name } else { result.recipients = append(result.recipients, transactionRecipient{ name: name, value: value, currency: currency, }) } } if name == "" || err != nil { return result, err } } } func readTransactionLeadingWhitespace(r *bufio.Reader) { b, err := r.Peek(2048) if err != nil && err != io.EOF { return } i := 0 for i < len(b) { if len(bytes.TrimSpace(b[:i])) != 0 { break } i++ } if i > 0 { r.Read(make([]byte, i-1)) } } func readTransactionLine(r *bufio.Reader) ([]byte, error) { for { b, err := _readTransactionLine(r) if err != nil || (len(bytes.TrimSpace(b)) > 0 && bytes.TrimSpace(b)[0] != '#') { return b, err } } } func _readTransactionLine(r *bufio.Reader) ([]byte, error) { b, err := r.Peek(2048) if len(b) == 0 { return nil, err } endOfLine := len(b) if idx := bytes.Index(b, []byte{'\n'}); idx > -1 { endOfLine = idx } b2 := make([]byte, endOfLine) n, err := r.Read(b2) if err == io.EOF { err = nil } if check, _ := r.Peek(1); len(check) == 1 && check[0] == '\n' { r.Read(make([]byte, 1)) } return b2[:n], err } func readTransactionAccount(r *bufio.Reader) (string, float64, string, error) { line, err := readTransactionLine(r) if err != nil { return "", 0, "", err } if len(line) > 0 && !unicode.IsSpace(rune(line[0])) { r2 := *r *r = *bufio.NewReader(io.MultiReader(bytes.NewReader(append(line, '\n')), &r2)) return "", 0, "", nil } fields := bytes.Fields(line) switch len(fields) { case 1: // payee return string(fields[0]), 0, "", nil case 2: // $00.00 b := bytes.TrimLeft(fields[1], "$") value, err := strconv.ParseFloat(string(b), 64) if err != nil { return "", 0, "", err } return string(fields[0]), value, string(USD), nil case 3: // 00.00 XYZ value, err := strconv.ParseFloat(string(fields[2]), 64) if err != nil { return "", 0, "", err } return string(fields[0]), value, string(fields[3]), nil default: return "", 0, "", fmt.Errorf("cannot interpret %q", line) } }