all repos

clerk @ 263a106703c6521b2fcef090c54f131040a34c13

missing tooling for ledger/hledger
33 files changed, 1484 insertions(+), 27 deletions(-)
formatter

- dont lex * and ! in payee name and description
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-06-07 16:49:36 +0300
Authored at: 2026-06-07 16:48:45 +0300
Change ID: mkuqlovqrzypwzywtouxulklvusxonul
Parent: e5cc255
A format.go
···
        
        1
        +package main

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"bytes"

      
        
        5
        +	"flag"

      
        
        6
        +	"fmt"

      
        
        7
        +	"io"

      
        
        8
        +	"os"

      
        
        9
        +

      
        
        10
        +	"olexsmir.xyz/clerk/journal"

      
        
        11
        +	"olexsmir.xyz/clerk/journal/printer"

      
        
        12
        +)

      
        
        13
        +

      
        
        14
        +func runFormat(args []string) {

      
        
        15
        +	fs := flag.NewFlagSet("format", flag.ExitOnError)

      
        
        16
        +	check := fs.Bool("c", false, "Exit code 0 if already formatted, 1 otherwise")

      
        
        17
        +	diff := fs.Bool("d", false, "Display diffs instead of rewriting files")

      
        
        18
        +	list := fs.Bool("l", false, "List files whose formatting differs")

      
        
        19
        +	write := fs.Bool("w", false, "Write result back to file instead of stdout")

      
        
        20
        +	fs.Usage = func() {

      
        
        21
        +		fmt.Fprintf(os.Stderr, "Usage: clerk format [flags] [path ...]\n")

      
        
        22
        +		fs.PrintDefaults()

      
        
        23
        +	}

      
        
        24
        +	fs.Parse(args)

      
        
        25
        +

      
        
        26
        +	paths := fs.Args()

      
        
        27
        +

      
        
        28
        +	// Read from stdin if no paths given

      
        
        29
        +	if len(paths) == 0 {

      
        
        30
        +		src, err := io.ReadAll(os.Stdin)

      
        
        31
        +		if err != nil {

      
        
        32
        +			fmt.Fprintf(os.Stderr, "error reading stdin: %v\n", err)

      
        
        33
        +			os.Exit(1)

      
        
        34
        +		}

      
        
        35
        +

      
        
        36
        +		pf, err := journal.NewLoader().LoadBytes("stdin", src)

      
        
        37
        +		if err != nil {

      
        
        38
        +			fmt.Fprintf(os.Stderr, "parse error: %v\n", err)

      
        
        39
        +			os.Exit(1)

      
        
        40
        +		}

      
        
        41
        +		if len(pf.Errors) > 0 {

      
        
        42
        +			fmt.Fprintf(os.Stderr, "parse error: %v\n", pf.Errors[0].Message)

      
        
        43
        +			os.Exit(1)

      
        
        44
        +		}

      
        
        45
        +

      
        
        46
        +		var buf bytes.Buffer

      
        
        47
        +		if err := printer.Fprint(&buf, pf.Ast); err != nil {

      
        
        48
        +			fmt.Fprintf(os.Stderr, "format error: %v\n", err)

      
        
        49
        +			os.Exit(1)

      
        
        50
        +		}

      
        
        51
        +

      
        
        52
        +		formatted := buf.Bytes()

      
        
        53
        +

      
        
        54
        +		switch {

      
        
        55
        +		case *check:

      
        
        56
        +			if bytes.Equal(src, formatted) {

      
        
        57
        +				os.Exit(0)

      
        
        58
        +			}

      
        
        59
        +			os.Exit(1)

      
        
        60
        +		case *diff:

      
        
        61
        +			diffLines("stdin", src, formatted)

      
        
        62
        +		default:

      
        
        63
        +			os.Stdout.Write(formatted)

      
        
        64
        +		}

      
        
        65
        +		return

      
        
        66
        +	}

      
        
        67
        +

      
        
        68
        +	// Process each file

      
        
        69
        +	exitCode := 0

      
        
        70
        +	for _, path := range paths {

      
        
        71
        +		info, err := os.Stat(path)

      
        
        72
        +		if err != nil {

      
        
        73
        +			fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err)

      
        
        74
        +			exitCode = 1

      
        
        75
        +			continue

      
        
        76
        +		}

      
        
        77
        +		if info.IsDir() {

      
        
        78
        +			fmt.Fprintf(os.Stderr, "error: %s: is a directory\n", path)

      
        
        79
        +			exitCode = 1

      
        
        80
        +			continue

      
        
        81
        +		}

      
        
        82
        +

      
        
        83
        +		src, err := os.ReadFile(path)

      
        
        84
        +		if err != nil {

      
        
        85
        +			fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err)

      
        
        86
        +			exitCode = 1

      
        
        87
        +			continue

      
        
        88
        +		}

      
        
        89
        +

      
        
        90
        +		pf, err := journal.NewLoader().LoadBytes(path, src)

      
        
        91
        +		if err != nil {

      
        
        92
        +			fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err)

      
        
        93
        +			exitCode = 1

      
        
        94
        +			continue

      
        
        95
        +		}

      
        
        96
        +		if len(pf.Errors) > 0 || len(pf.FileErrors) > 0 {

      
        
        97
        +			fmt.Fprintf(os.Stderr, "error: %s: has errors, refusing to format\n", path)

      
        
        98
        +			exitCode = 1

      
        
        99
        +			continue

      
        
        100
        +		}

      
        
        101
        +

      
        
        102
        +		var buf bytes.Buffer

      
        
        103
        +		if err := printer.Fprint(&buf, pf.Ast); err != nil {

      
        
        104
        +			fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err)

      
        
        105
        +			exitCode = 1

      
        
        106
        +			continue

      
        
        107
        +		}

      
        
        108
        +

      
        
        109
        +		formatted := buf.Bytes()

      
        
        110
        +		changed := !bytes.Equal(src, formatted)

      
        
        111
        +

      
        
        112
        +		switch {

      
        
        113
        +		case *check:

      
        
        114
        +			if changed {

      
        
        115
        +				fmt.Fprintf(os.Stderr, "%s: not formatted\n", path)

      
        
        116
        +				exitCode = 1

      
        
        117
        +			}

      
        
        118
        +		case *list:

      
        
        119
        +			if changed {

      
        
        120
        +				fmt.Println(path)

      
        
        121
        +			}

      
        
        122
        +		case *diff:

      
        
        123
        +			if changed {

      
        
        124
        +				diffLines(path, src, formatted)

      
        
        125
        +			}

      
        
        126
        +		case *write:

      
        
        127
        +			if changed {

      
        
        128
        +				if err := os.WriteFile(path, formatted, 0o644); err != nil {

      
        
        129
        +					fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err)

      
        
        130
        +					exitCode = 1

      
        
        131
        +				}

      
        
        132
        +			}

      
        
        133
        +		default:

      
        
        134
        +			if _, err := os.Stdout.Write(formatted); err != nil {

      
        
        135
        +				fmt.Fprintf(os.Stderr, "error writing stdout: %v\n", err)

      
        
        136
        +				exitCode = 1

      
        
        137
        +			}

      
        
        138
        +		}

      
        
        139
        +	}

      
        
        140
        +	os.Exit(exitCode)

      
        
        141
        +}

      
        
        142
        +

      
        
        143
        +func diffLines(path string, src, formatted []byte) {

      
        
        144
        +	fmt.Printf("--- %s\n+++ %s\n", path, path)

      
        
        145
        +	srcLines := bytes.Split(src, []byte("\n"))

      
        
        146
        +	fmtLines := bytes.Split(formatted, []byte("\n"))

      
        
        147
        +	lines := max(len(fmtLines), len(srcLines))

      
        
        148
        +	for i := range lines {

      
        
        149
        +		var sLine, fLine []byte

      
        
        150
        +		if i < len(srcLines) {

      
        
        151
        +			sLine = srcLines[i]

      
        
        152
        +		}

      
        
        153
        +		if i < len(fmtLines) {

      
        
        154
        +			fLine = fmtLines[i]

      
        
        155
        +		}

      
        
        156
        +		if !bytes.Equal(sLine, fLine) {

      
        
        157
        +			if len(sLine) > 0 {

      
        
        158
        +				fmt.Printf("-%s\n", sLine)

      
        
        159
        +			} else {

      
        
        160
        +				fmt.Println("-")

      
        
        161
        +			}

      
        
        162
        +			if len(fLine) > 0 {

      
        
        163
        +				fmt.Printf("+%s\n", fLine)

      
        
        164
        +			} else {

      
        
        165
        +				fmt.Println("+")

      
        
        166
        +			}

      
        
        167
        +		}

      
        
        168
        +	}

      
        
        169
        +}

      
M internal/decimal/decimal.go
···
        101
        101
         	return sign + digits[:split] + "." + digits[split:]

      
        102
        102
         }

      
        103
        103
         

      
        
        104
        +// StringFixed returns a string representation with exactly places digits

      
        
        105
        +// after the decimal point. Pads with zeros or truncates as needed.

      
        
        106
        +// decSep and thousandsSep control formatting; zero values mean no custom separator.

      
        
        107
        +func (d Decimal) StringFixed(places int, decSep, thousandsSep byte) string {

      
        
        108
        +	var sb strings.Builder

      
        
        109
        +	sb.Grow(32)

      
        
        110
        +	d.WriteFixed(&sb, places, decSep, thousandsSep)

      
        
        111
        +	return sb.String()

      
        
        112
        +}

      
        
        113
        +

      
        
        114
        +// WriteFixed writes a string representation with exactly places digits

      
        
        115
        +// after the decimal point directly into sb. Pads with zeros or truncates

      
        
        116
        +// as needed. decSep and thousandsSep control formatting; zero values mean

      
        
        117
        +// no custom separator.

      
        
        118
        +func (d Decimal) WriteFixed(sb *strings.Builder, places int, decSep, thousandsSep byte) {

      
        
        119
        +	if d.IsZero() {

      
        
        120
        +		sb.WriteByte('0')

      
        
        121
        +		if places > 0 {

      
        
        122
        +			if decSep != 0 {

      
        
        123
        +				sb.WriteByte(decSep)

      
        
        124
        +			} else {

      
        
        125
        +				sb.WriteByte('.')

      
        
        126
        +			}

      
        
        127
        +			for range places {

      
        
        128
        +				sb.WriteByte('0')

      
        
        129
        +			}

      
        
        130
        +		}

      
        
        131
        +		return

      
        
        132
        +	}

      
        
        133
        +

      
        
        134
        +	var digitBuf [128]byte

      
        
        135
        +	digits := d.coeff.Append(digitBuf[:0], 10)

      
        
        136
        +

      
        
        137
        +	sign := false

      
        
        138
        +	if len(digits) > 0 && digits[0] == '-' {

      
        
        139
        +		sign = true

      
        
        140
        +		digits = digits[1:]

      
        
        141
        +	}

      
        
        142
        +

      
        
        143
        +	intLen := len(digits) - d.scale

      
        
        144
        +

      
        
        145
        +	if sign {

      
        
        146
        +		sb.WriteByte('-')

      
        
        147
        +	}

      
        
        148
        +

      
        
        149
        +	// Integer part

      
        
        150
        +	if intLen <= 0 {

      
        
        151
        +		sb.WriteByte('0')

      
        
        152
        +	} else {

      
        
        153
        +		for i := range intLen {

      
        
        154
        +			if thousandsSep != 0 && i > 0 && (intLen-i)%3 == 0 {

      
        
        155
        +				sb.WriteByte(thousandsSep)

      
        
        156
        +			}

      
        
        157
        +			sb.WriteByte(digits[i])

      
        
        158
        +		}

      
        
        159
        +	}

      
        
        160
        +

      
        
        161
        +	// Fractional part

      
        
        162
        +	if places > 0 {

      
        
        163
        +		if decSep != 0 {

      
        
        164
        +			sb.WriteByte(decSep)

      
        
        165
        +		} else {

      
        
        166
        +			sb.WriteByte('.')

      
        
        167
        +		}

      
        
        168
        +

      
        
        169
        +		written := 0

      
        
        170
        +		if intLen > 0 {

      
        
        171
        +			n := min(d.scale, places)

      
        
        172
        +			for i := range n {

      
        
        173
        +				sb.WriteByte(digits[intLen+i])

      
        
        174
        +				written++

      
        
        175
        +			}

      
        
        176
        +		} else {

      
        
        177
        +			leadingZeros := -intLen

      
        
        178
        +			for ; written < leadingZeros && written < places; written++ {

      
        
        179
        +				sb.WriteByte('0')

      
        
        180
        +			}

      
        
        181
        +			for i := 0; i < len(digits) && written < places; i++ {

      
        
        182
        +				sb.WriteByte(digits[i])

      
        
        183
        +				written++

      
        
        184
        +			}

      
        
        185
        +		}

      
        
        186
        +		for ; written < places; written++ {

      
        
        187
        +			sb.WriteByte('0')

      
        
        188
        +		}

      
        
        189
        +	}

      
        
        190
        +}

      
        
        191
        +

      
        104
        192
         func (d Decimal) Neg() Decimal {

      
        105
        193
         	if d.coeff == nil || d.coeff.Sign() == 0 {

      
        106
        194
         		return Decimal{}

      
M internal/decimal/decimal_test.go
···
        28
        28
         

      
        29
        29
         func TestNewFromStringInvalid(t *testing.T) {

      
        30
        30
         	tests := []string{

      
        31
        
        -		"",

      
        32
        
        -		".",

      
        33
        
        -		"+",

      
        34
        
        -		"-",

      
        35
        
        -		"1_000.00",

      
        36
        
        -		"1,000.00",

      
        
        31
        +		"", ".", "+", "-",

      
        
        32
        +		"1_000.00", "1,000.00",

      
        37
        33
         		"1..0",

      
        38
        
        -		"a1",

      
        39
        
        -		"1a",

      
        
        34
        +		"a1", "1a",

      
        40
        35
         	}

      
        41
        36
         	for _, in := range tests {

      
        42
        37
         		if _, err := FromString(in); err == nil {

      
        43
        38
         			t.Fatalf("NewFromString(%q) expected error", in)

      
        
        39
        +		}

      
        
        40
        +	}

      
        
        41
        +}

      
        
        42
        +

      
        
        43
        +func TestStringFixed(t *testing.T) {

      
        
        44
        +	tests := []struct {

      
        
        45
        +		in           string

      
        
        46
        +		places       int

      
        
        47
        +		decSep       byte

      
        
        48
        +		thousandsSep byte

      
        
        49
        +		want         string

      
        
        50
        +	}{

      
        
        51
        +		{"0", 0, 0, 0, "0"},

      
        
        52
        +		{"0", 1, 0, 0, "0.0"},

      
        
        53
        +		{"0", 2, 0, 0, "0.00"},

      
        
        54
        +		{"0", 3, 0, 0, "0.000"},

      
        
        55
        +		{"-0", 2, 0, 0, "0.00"},

      
        
        56
        +		{"10", 2, 0, 0, "10.00"},

      
        
        57
        +		{"10", 0, 0, 0, "10"},

      
        
        58
        +		{"1.2", 2, 0, 0, "1.20"},

      
        
        59
        +		{"1.2", 1, 0, 0, "1.2"},

      
        
        60
        +		{"1.2", 3, 0, 0, "1.200"},

      
        
        61
        +		{"1.234", 2, 0, 0, "1.23"},

      
        
        62
        +		{"1.235", 2, 0, 0, "1.23"},

      
        
        63
        +		{"-1.2", 2, 0, 0, "-1.20"},

      
        
        64
        +		{"-1.234", 2, 0, 0, "-1.23"},

      
        
        65
        +		{"0.001", 2, 0, 0, "0.00"},

      
        
        66
        +		{"0.001", 4, 0, 0, "0.0010"},

      
        
        67
        +		{"123.456789", 3, 0, 0, "123.456"},

      
        
        68
        +		{"-123.456789", 3, 0, 0, "-123.456"},

      
        
        69
        +		{"1.23", 2, ',', 0, "1,23"},

      
        
        70
        +		{"0", 2, ',', 0, "0,00"},

      
        
        71
        +		{"-1.5", 2, ',', 0, "-1,50"},

      
        
        72
        +		{"1234.56", 2, 0, ',', "1,234.56"},

      
        
        73
        +		{"1234567.89", 2, 0, ',', "1,234,567.89"},

      
        
        74
        +		{"123.45", 2, 0, ',', "123.45"},

      
        
        75
        +		{"-1234.56", 2, 0, ',', "-1,234.56"},

      
        
        76
        +		{"1234567.89", 2, ',', '.', "1.234.567,89"},

      
        
        77
        +		{"-1234567.89", 2, ',', '.', "-1.234.567,89"},

      
        
        78
        +		{"0", 2, ',', '.', "0,00"},

      
        
        79
        +	}

      
        
        80
        +	for _, tt := range tests {

      
        
        81
        +		d, err := FromString(tt.in)

      
        
        82
        +		if err != nil {

      
        
        83
        +			t.Fatalf("FromString(%q) unexpected error: %v", tt.in, err)

      
        
        84
        +		}

      
        
        85
        +		if got := d.StringFixed(tt.places, tt.decSep, tt.thousandsSep); got != tt.want {

      
        
        86
        +			t.Fatalf("FromString(%q).StringFixed(%d, %q, %q) = %q, want %q",

      
        
        87
        +				tt.in, tt.places, tt.decSep, tt.thousandsSep, got, tt.want)

      
        44
        88
         		}

      
        45
        89
         	}

      
        46
        90
         }

      
M journal/ast/dump.go
···
        147
        147
         		indent(b, depth+1)

      
        148
        148
         		fmt.Fprintf(b, "HeaderComments %s\n", t.Span)

      
        149
        149
         		for _, c := range t.HeaderComments {

      
        150
        
        -			dumpComment(b, &c, depth+2)

      
        
        150
        +			dumpComment(b, c, depth+2)

      
        151
        151
         		}

      
        152
        152
         	}

      
        153
        153
         	for _, p := range t.Postings {

      
M journal/ast/entries.go
···
        11
        11
         

      
        12
        12
         type Transaction struct {

      
        13
        13
         	Date           Date

      
        14
        
        -	SecondDate     *Date     // optional =2026-05-18 date

      
        15
        
        -	Status         *Status   // optional */! status

      
        16
        
        -	Code           *string   // optional (123) code

      
        17
        
        -	Payee          *Payee    // optional payee

      
        18
        
        -	Note           *string   // part after |

      
        19
        
        -	Comment        *Comment  // inline ; on header line

      
        20
        
        -	HeaderComments []Comment // indented ; lines before first posting

      
        
        14
        +	SecondDate     *Date      // optional =2026-05-18 date

      
        
        15
        +	Status         *Status    // optional */! status

      
        
        16
        +	Code           *string    // optional (123) code

      
        
        17
        +	Payee          *Payee     // optional payee

      
        
        18
        +	Note           *string    // part after |

      
        
        19
        +	Comment        *Comment   // inline ; on header line

      
        
        20
        +	HeaderComments []*Comment // indented ; lines before first posting

      
        21
        21
         	Postings       []*Posting

      
        22
        22
         	Span           token.Span

      
        23
        23
         }

      
M journal/lexer/lexer.go
···
        51
        51
         	col    int  // current column (1-based)

      
        52
        52
         	line   int  // current line (1-based)

      
        53
        53
         

      
        54
        
        -	postingExpectAccount bool

      
        
        54
        +	transactionPastStatus bool

      
        
        55
        +	postingExpectAccount  bool

      
        55
        56
         }

      
        56
        57
         

      
        57
        58
         func New(file string, input []byte) *Lexer {

      ···
        137
        138
         		}

      
        138
        139
         		tok := l.lexDate()

      
        139
        140
         		l.mode = ModeTransaction

      
        
        141
        +		l.transactionPastStatus = false

      
        140
        142
         		return tok

      
        141
        143
         	default:

      
        142
        144
         		s := l.save()

      ···
        183
        185
         		return l.lexSingle(token.SEMICOLON)

      
        184
        186
         	case ' ', '\t':

      
        185
        187
         		return l.lexWhitespace()

      
        186
        
        -	case '*': // * after date = status

      
        187
        
        -		return l.lexSingle(token.STAR)

      
        
        188
        +	case '*':

      
        
        189
        +		if !l.transactionPastStatus {

      
        
        190
        +			l.transactionPastStatus = true

      
        
        191
        +			return l.lexSingle(token.STAR)

      
        
        192
        +		}

      
        
        193
        +		return l.lexText()

      
        188
        194
         	case '!':

      
        189
        
        -		return l.lexSingle(token.BANG)

      
        
        195
        +		if !l.transactionPastStatus {

      
        
        196
        +			l.transactionPastStatus = true

      
        
        197
        +			return l.lexSingle(token.BANG)

      
        
        198
        +		}

      
        
        199
        +		return l.lexText()

      
        190
        200
         	case '|':

      
        191
        201
         		return l.lexSingle(token.PIPE)

      
        192
        202
         	case '+':

      ···
        216
        226
         		l.col = 0

      
        217
        227
         		l.advance()

      
        218
        228
         		return l.lexNewline()

      
        219
        
        -	case ';', '%', '#':

      
        
        229
        +	case ';':

      
        220
        230
         		l.mode = ModeComment

      
        221
        
        -		return l.lexSingle(token.SEMICOLON) // todo: ??

      
        
        231
        +		return l.lexSingle(token.SEMICOLON)

      
        222
        232
         	case ' ', '\t':

      
        223
        233
         		return l.lexWhitespace()

      
        224
        234
         	default:

      ···
        239
        249
         		return l.lexNewline()

      
        240
        250
         	case ' ', '\t':

      
        241
        251
         		return l.lexWhitespace()

      
        242
        
        -	case ';', '%', '#':

      
        
        252
        +	case ';':

      
        243
        253
         		l.mode = ModeComment

      
        244
        
        -		return l.lexSingle(token.SEMICOLON) // todo: ??

      
        
        254
        +		return l.lexSingle(token.SEMICOLON)

      
        245
        255
         	default:

      
        246
        256
         		return l.lexText()

      
        247
        257
         	}

      
M journal/lexer/lexer_test.go
···
        46
        46
             expenses:food  40.00 гривні

      
        47
        47
             assets:cash

      
        48
        48
         `},

      
        
        49
        +{"bangs and stars in transaction description", `2026-06-07 * payee !one | something *important*

      
        
        50
        +    expenses:food  40.00

      
        
        51
        +    assets:cash

      
        
        52
        +`},

      
        49
        53
         		{"date with secondary", `2024/01/01=2024/01/02 groceries`},

      
        50
        54
         		{"better date", `2024-01-02`},

      
        51
        55
         		{"comment line", `; this is a comment`},

      
A journal/lexer/testdata/golden/Lexer__bangs_and_stars_in_transaction_description.golden
···
        
        1
        +DATE         "2026-06-07"         1:1-1:11

      
        
        2
        +WHITESPACE   " "                  1:11-1:12

      
        
        3
        +STAR         "*"                  1:12-1:13

      
        
        4
        +WHITESPACE   " "                  1:13-1:14

      
        
        5
        +TEXT         "payee"              1:14-1:19

      
        
        6
        +WHITESPACE   " "                  1:19-1:20

      
        
        7
        +TEXT         "!one"               1:20-1:24

      
        
        8
        +WHITESPACE   " "                  1:24-1:25

      
        
        9
        +PIPE         "|"                  1:25-1:26

      
        
        10
        +WHITESPACE   " "                  1:26-1:27

      
        
        11
        +TEXT         "something"          1:27-1:36

      
        
        12
        +WHITESPACE   " "                  1:36-1:37

      
        
        13
        +TEXT         "*important*"        1:37-2:0

      
        
        14
        +NEWLINE      "\n"                 2:0-2:1

      
        
        15
        +INDENT       "    "               2:1-2:5

      
        
        16
        +TEXT         "expenses:food"      2:5-2:18

      
        
        17
        +WHITESPACE   "  "                 2:18-2:20

      
        
        18
        +DECIMAL      "40.00"              2:20-3:0

      
        
        19
        +NEWLINE      "\n"                 3:0-3:1

      
        
        20
        +INDENT       "    "               3:1-3:5

      
        
        21
        +TEXT         "assets:cash"        3:5-4:0

      
        
        22
        +NEWLINE      "\n"                 4:0-4:1

      
        
        23
        +EOF          ""                   4:1-4:1

      
M journal/parser/parser.go
···
        167
        167
         	for p.got(token.INDENT) && p.willGet(token.SEMICOLON) {

      
        168
        168
         		p.advance() // consume indent

      
        169
        169
         		c := p.parseComment()

      
        170
        
        -		tx.HeaderComments = append(tx.HeaderComments, *c)

      
        
        170
        +		tx.HeaderComments = append(tx.HeaderComments, c)

      
        171
        171
         	}

      
        172
        172
         

      
        173
        173
         	// postings

      ···
        1099
        1099
         

      
        1100
        1100
         func (p *Parser) parseOptInlineComment() *ast.Comment {

      
        1101
        1101
         	p.skipWhitespace() // todo:

      
        1102
        
        -	if p.cur.Type != token.SEMICOLON && p.cur.Type != token.HASH {

      
        
        1102
        +	if p.cur.Type != token.SEMICOLON {

      
        1103
        1103
         		return nil

      
        1104
        1104
         	}

      
        1105
        1105
         

      
A journal/printer/amount.go
···
        
        1
        +package printer

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"olexsmir.xyz/clerk/internal/decimal"

      
        
        5
        +	"olexsmir.xyz/clerk/journal/ast"

      
        
        6
        +)

      
        
        7
        +

      
        
        8
        +func (p *printer) writeAmount(a *ast.Amount, pos CommodityPos) {

      
        
        9
        +	if a == nil {

      
        
        10
        +		return

      
        
        11
        +	}

      
        
        12
        +

      
        
        13
        +	if a.IsExpr {

      
        
        14
        +		p.buf.WriteString(a.Expr)

      
        
        15
        +		return

      
        
        16
        +	}

      
        
        17
        +

      
        
        18
        +	comm := a.Commodity

      
        
        19
        +	if comm == "" {

      
        
        20
        +		p.writeDecimal(a.Quantity, a.QuantityFmt, 2)

      
        
        21
        +		return

      
        
        22
        +	}

      
        
        23
        +

      
        
        24
        +	switch pos {

      
        
        25
        +	case CommodityBefore:

      
        
        26
        +		p.buf.WriteString(comm)

      
        
        27
        +		if a.HasSpace {

      
        
        28
        +			p.buf.WriteByte(' ')

      
        
        29
        +		}

      
        
        30
        +		p.writeDecimal(a.Quantity, a.QuantityFmt, 2)

      
        
        31
        +	case CommodityAfter:

      
        
        32
        +		p.writeDecimal(a.Quantity, a.QuantityFmt, 2)

      
        
        33
        +		if a.HasSpace {

      
        
        34
        +			p.buf.WriteByte(' ')

      
        
        35
        +		}

      
        
        36
        +		p.buf.WriteString(comm)

      
        
        37
        +	default:

      
        
        38
        +		panic("impossible CommodityPos value")

      
        
        39
        +	}

      
        
        40
        +}

      
        
        41
        +

      
        
        42
        +func (p *printer) writeCost(c *ast.Cost, pos CommodityPos) {

      
        
        43
        +	if c == nil {

      
        
        44
        +		return

      
        
        45
        +	}

      
        
        46
        +	if c.IsTotal {

      
        
        47
        +		p.buf.WriteString(" @@ ")

      
        
        48
        +	} else {

      
        
        49
        +		p.buf.WriteString(" @ ")

      
        
        50
        +	}

      
        
        51
        +	p.writeAmount(&c.Amount, pos)

      
        
        52
        +}

      
        
        53
        +

      
        
        54
        +func (p *printer) writeBalanceAssertion(ba *ast.BalanceAssertion, pos CommodityPos) {

      
        
        55
        +	if ba == nil {

      
        
        56
        +		return

      
        
        57
        +	}

      
        
        58
        +	switch {

      
        
        59
        +	case ba.IsInclusive:

      
        
        60
        +		p.buf.WriteString("=== ")

      
        
        61
        +	case ba.IsStrict:

      
        
        62
        +		p.buf.WriteString("== ")

      
        
        63
        +	default:

      
        
        64
        +		p.buf.WriteString("= ")

      
        
        65
        +	}

      
        
        66
        +	p.writeAmount(&ba.Amount, pos)

      
        
        67
        +}

      
        
        68
        +

      
        
        69
        +func (p *printer) writeDecimal(d decimal.Decimal, fmt ast.QuantityFormat, forcePrec int) {

      
        
        70
        +	d.WriteFixed(&p.buf, forcePrec, fmt.Decimal, fmt.Thousands)

      
        
        71
        +}

      
A journal/printer/directives.go
···
        
        1
        +package printer

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"strconv"

      
        
        5
        +	"strings"

      
        
        6
        +

      
        
        7
        +	"olexsmir.xyz/clerk/journal/ast"

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +func (p *printer) writeIncludeDirective(i *ast.IncludeDirective) {

      
        
        11
        +	p.buf.WriteString("include ")

      
        
        12
        +	p.buf.WriteString(quoteString(i.Path))

      
        
        13
        +	p.writeInlineComment(i.Comment)

      
        
        14
        +}

      
        
        15
        +

      
        
        16
        +func (p *printer) writeAccountDirective(a *ast.AccountDirective) {

      
        
        17
        +	p.buf.WriteString("account ")

      
        
        18
        +	p.buf.WriteString(a.Account.Name)

      
        
        19
        +	p.writeInlineComment(a.Comment)

      
        
        20
        +}

      
        
        21
        +

      
        
        22
        +func (p *printer) writeCommodityDirective(c *ast.CommodityDirective) {

      
        
        23
        +	p.buf.WriteString("commodity ")

      
        
        24
        +	p.buf.WriteString(c.Commodity)

      
        
        25
        +	p.writeInlineComment(c.Comment)

      
        
        26
        +}

      
        
        27
        +

      
        
        28
        +func (p *printer) writeAliasDirective(a *ast.AliasDirective) {

      
        
        29
        +	p.buf.WriteString("alias ")

      
        
        30
        +	p.buf.WriteString(a.From)

      
        
        31
        +	p.buf.WriteString(" = ")

      
        
        32
        +	p.buf.WriteString(a.To)

      
        
        33
        +	p.writeInlineComment(a.Comment)

      
        
        34
        +}

      
        
        35
        +

      
        
        36
        +func (p *printer) writePayeeDirective(pd *ast.PayeeDirective) {

      
        
        37
        +	p.buf.WriteString("payee ")

      
        
        38
        +	p.buf.WriteString(quoteString(pd.Name))

      
        
        39
        +	p.writeInlineComment(pd.Comment)

      
        
        40
        +}

      
        
        41
        +

      
        
        42
        +func (p *printer) writeTagDirective(t *ast.TagDirective) {

      
        
        43
        +	p.buf.WriteString("tag ")

      
        
        44
        +	p.buf.WriteString(quoteString(t.Name))

      
        
        45
        +	p.writeInlineComment(t.Comment)

      
        
        46
        +}

      
        
        47
        +

      
        
        48
        +func (p *printer) writeYearDirective(y *ast.YearDirective) {

      
        
        49
        +	p.buf.WriteString("year ")

      
        
        50
        +	p.writeInt(y.Year, 4)

      
        
        51
        +	p.writeInlineComment(y.Comment)

      
        
        52
        +}

      
        
        53
        +

      
        
        54
        +func (p *printer) writeDecimalMarkDirective(d *ast.DecimalMarkDirective) {

      
        
        55
        +	p.buf.WriteString("decimal-mark ")

      
        
        56
        +	p.buf.WriteByte(d.Mark)

      
        
        57
        +	p.writeInlineComment(d.Comment)

      
        
        58
        +}

      
        
        59
        +

      
        
        60
        +func (p *printer) writeDefaultCommodityDirective(d *ast.DefaultCommodityDirective) {

      
        
        61
        +	p.buf.WriteString("D ")

      
        
        62
        +	p.writeAmount(&d.Amount, p.cfg.CommodityPos)

      
        
        63
        +	p.writeInlineComment(d.Comment)

      
        
        64
        +}

      
        
        65
        +

      
        
        66
        +func (p *printer) writeConversionDirective(c *ast.ConversionDirective) {

      
        
        67
        +	p.buf.WriteString("C ")

      
        
        68
        +	p.writeAmount(&c.From, p.cfg.CommodityPos)

      
        
        69
        +	p.buf.WriteString(" = ")

      
        
        70
        +	p.writeAmount(&c.To, p.cfg.CommodityPos)

      
        
        71
        +	p.writeInlineComment(c.Comment)

      
        
        72
        +}

      
        
        73
        +

      
        
        74
        +func (p *printer) writeMarketPriceDirective(m *ast.MarketPriceDirective) {

      
        
        75
        +	p.buf.WriteString("P ")

      
        
        76
        +	p.writeDate(m.DateTime.Date)

      
        
        77
        +	p.writeTime(m.DateTime.Time)

      
        
        78
        +	p.buf.WriteByte(' ')

      
        
        79
        +	p.buf.WriteString(m.Commodity)

      
        
        80
        +	p.buf.WriteByte(' ')

      
        
        81
        +	p.writeAmount(&m.Amount, p.cfg.CommodityPos)

      
        
        82
        +	p.writeInlineComment(m.Comment)

      
        
        83
        +}

      
        
        84
        +

      
        
        85
        +func (p *printer) writeCommentBlockDirective(cb *ast.CommentBlockDirective) {

      
        
        86
        +	p.buf.WriteString("comment")

      
        
        87
        +	if cb.Header != "" {

      
        
        88
        +		p.buf.WriteByte(' ')

      
        
        89
        +		p.buf.WriteString(cb.Header)

      
        
        90
        +	}

      
        
        91
        +	p.buf.WriteByte('\n')

      
        
        92
        +	if cb.Content != "" {

      
        
        93
        +		p.buf.WriteString(cb.Content)

      
        
        94
        +		if !strings.HasSuffix(cb.Content, "\n") {

      
        
        95
        +			p.buf.WriteByte('\n')

      
        
        96
        +		}

      
        
        97
        +	}

      
        
        98
        +	p.buf.WriteString("end")

      
        
        99
        +	if cb.Header != "" {

      
        
        100
        +		p.buf.WriteByte(' ')

      
        
        101
        +		p.buf.WriteString(cb.Header)

      
        
        102
        +	}

      
        
        103
        +}

      
        
        104
        +

      
        
        105
        +func (p *printer) writeApplyDirective(a *ast.ApplyDirective) {

      
        
        106
        +	p.buf.WriteString("apply ")

      
        
        107
        +	p.buf.WriteString(a.Expr)

      
        
        108
        +	p.writeInlineComment(a.Comment)

      
        
        109
        +}

      
        
        110
        +

      
        
        111
        +func (p *printer) writeEndDirective(e *ast.EndDirective) {

      
        
        112
        +	p.buf.WriteString("end")

      
        
        113
        +	if e.Expr != "" {

      
        
        114
        +		p.buf.WriteByte(' ')

      
        
        115
        +		p.buf.WriteString(e.Expr)

      
        
        116
        +	}

      
        
        117
        +	p.writeInlineComment(e.Comment)

      
        
        118
        +}

      
        
        119
        +

      
        
        120
        +func quoteString(s string) string {

      
        
        121
        +	needsQuote := false

      
        
        122
        +	for _, c := range s {

      
        
        123
        +		if c == ' ' || c == '\t' || c == '"' || c == ';' || c == '#' {

      
        
        124
        +			needsQuote = true

      
        
        125
        +			break

      
        
        126
        +		}

      
        
        127
        +	}

      
        
        128
        +	if !needsQuote {

      
        
        129
        +		return s

      
        
        130
        +	}

      
        
        131
        +	return strconv.Quote(s)

      
        
        132
        +}

      
M journal/printer/printer.go
···
        1
        1
         package printer

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +	"io"

      
        
        6
        +	"strings"

      
        
        7
        +

      
        
        8
        +	"olexsmir.xyz/clerk/journal/ast"

      
        
        9
        +)

      
        
        10
        +

      
        
        11
        +// AlignStyle controls how postings are aligned

      
        
        12
        +type AlignStyle int

      
        
        13
        +

      
        
        14
        +const (

      
        
        15
        +	AlignTwoSpaces AlignStyle = iota // "  Account  $10.00"

      
        
        16
        +	AlignRight                       // amounts at fixed col: "  Account         $10.00"

      
        
        17
        +	AlignTab                         // elastic tabstops

      
        
        18
        +)

      
        
        19
        +

      
        
        20
        +// CommodityPos controls where the commodity marker is placed

      
        
        21
        +type CommodityPos int

      
        
        22
        +

      
        
        23
        +const (

      
        
        24
        +	CommodityAfter  CommodityPos = iota // "10.00 EUR"

      
        
        25
        +	CommodityBefore                     // "$10.00"

      
        
        26
        +)

      
        
        27
        +

      
        
        28
        +type Config struct {

      
        
        29
        +	TabIndent    bool         // true = tabs, false = spaces

      
        
        30
        +	IndentWidth  int          // spaces per indent level (default: 2)

      
        
        31
        +	AlignStyle   AlignStyle   // (default AlignTwoSpaces)

      
        
        32
        +	AlignColumn  int          // fixed column for AlignRight

      
        
        33
        +	CommodityPos CommodityPos // where to place commodity

      
        
        34
        +}

      
        
        35
        +

      
        
        36
        +var defaultConfig = &Config{

      
        
        37
        +	TabIndent:    false,

      
        
        38
        +	IndentWidth:  2,

      
        
        39
        +	AlignStyle:   AlignTwoSpaces,

      
        
        40
        +	AlignColumn:  70,

      
        
        41
        +	CommodityPos: CommodityAfter,

      
        
        42
        +}

      
        
        43
        +

      
        
        44
        +func (c *Config) indent() string {

      
        
        45
        +	if c.TabIndent {

      
        
        46
        +		return "\t"

      
        
        47
        +	}

      
        
        48
        +	n := 2

      
        
        49
        +	if c.IndentWidth > 0 {

      
        
        50
        +		n = c.IndentWidth

      
        
        51
        +	}

      
        
        52
        +	return spaces(n)

      
        
        53
        +}

      
        
        54
        +

      
        
        55
        +// printer holds formatting state for a single Fprint call.

      
        
        56
        +type printer struct {

      
        
        57
        +	buf    strings.Builder

      
        
        58
        +	cfg    *Config

      
        
        59
        +	indent string

      
        
        60
        +}

      
        
        61
        +

      
        
        62
        +// Fprint formats using the default config.

      
        
        63
        +func Fprint(w io.Writer, j *ast.Journal) error { return defaultConfig.Fprint(w, j) }

      
        
        64
        +

      
        
        65
        +// Fprint formats a parsed journal.

      
        
        66
        +func (c *Config) Fprint(w io.Writer, j *ast.Journal) error {

      
        
        67
        +	if c == nil {

      
        
        68
        +		c = defaultConfig

      
        
        69
        +	}

      
        
        70
        +	p := printer{cfg: c, indent: c.indent()}

      
        
        71
        +

      
        
        72
        +	for _, e := range j.Entries {

      
        
        73
        +		p.formatEntry(e)

      
        
        74
        +	}

      
        
        75
        +

      
        
        76
        +	// allow exactly one trailing newline

      
        
        77
        +	out := strings.TrimRight(p.buf.String(), "\n") + "\n"

      
        
        78
        +	_, err := io.WriteString(w, out)

      
        
        79
        +	return err

      
        
        80
        +}

      
        
        81
        +

      
        
        82
        +// FprintEntry formats a single ast entry using the default config.

      
        
        83
        +func FprintEntry(w io.Writer, e ast.Entry) error {

      
        
        84
        +	return defaultConfig.FprintEntry(w, e)

      
        
        85
        +}

      
        
        86
        +

      
        
        87
        +// FprintEntry formats a single journal entry.

      
        
        88
        +func (c *Config) FprintEntry(w io.Writer, e ast.Entry) error {

      
        
        89
        +	if c == nil {

      
        
        90
        +		c = defaultConfig

      
        
        91
        +	}

      
        
        92
        +	p := printer{cfg: c, indent: c.indent()}

      
        
        93
        +	p.formatEntry(e)

      
        
        94
        +	_, err := io.WriteString(w, p.buf.String())

      
        
        95
        +	return err

      
        
        96
        +}

      
        
        97
        +

      
        
        98
        +func (p *printer) formatEntry(e ast.Entry) {

      
        
        99
        +	switch e := e.(type) {

      
        
        100
        +	case *ast.Transaction:

      
        
        101
        +		p.writeTransaction(e)

      
        
        102
        +		return

      
        
        103
        +	case *ast.PeriodicTransaction:

      
        
        104
        +		p.writePeriodicTransaction(e)

      
        
        105
        +		return

      
        
        106
        +	case *ast.AutomatedTransaction:

      
        
        107
        +		p.writeAutomatedTransaction(e)

      
        
        108
        +		return

      
        
        109
        +	case *ast.BlankLine:

      
        
        110
        +		p.buf.WriteByte('\n')

      
        
        111
        +		return

      
        
        112
        +	case *ast.IgnoredDirective:

      
        
        113
        +		return // TODO:

      
        
        114
        +

      
        
        115
        +	case *ast.Comment:

      
        
        116
        +		p.writeComment(e)

      
        
        117
        +	case *ast.AccountDirective:

      
        
        118
        +		p.writeAccountDirective(e)

      
        
        119
        +	case *ast.CommodityDirective:

      
        
        120
        +		p.writeCommodityDirective(e)

      
        
        121
        +	case *ast.IncludeDirective:

      
        
        122
        +		p.writeIncludeDirective(e)

      
        
        123
        +	case *ast.AliasDirective:

      
        
        124
        +		p.writeAliasDirective(e)

      
        
        125
        +	case *ast.PayeeDirective:

      
        
        126
        +		p.writePayeeDirective(e)

      
        
        127
        +	case *ast.TagDirective:

      
        
        128
        +		p.writeTagDirective(e)

      
        
        129
        +	case *ast.YearDirective:

      
        
        130
        +		p.writeYearDirective(e)

      
        
        131
        +	case *ast.DecimalMarkDirective:

      
        
        132
        +		p.writeDecimalMarkDirective(e)

      
        
        133
        +	case *ast.MarketPriceDirective:

      
        
        134
        +		p.writeMarketPriceDirective(e)

      
        
        135
        +	case *ast.ConversionDirective:

      
        
        136
        +		p.writeConversionDirective(e)

      
        
        137
        +	case *ast.DefaultCommodityDirective:

      
        
        138
        +		p.writeDefaultCommodityDirective(e)

      
        
        139
        +	case *ast.ApplyDirective:

      
        
        140
        +		p.writeApplyDirective(e)

      
        
        141
        +	case *ast.EndDirective:

      
        
        142
        +		p.writeEndDirective(e)

      
        
        143
        +	case *ast.CommentBlockDirective:

      
        
        144
        +		p.writeCommentBlockDirective(e)

      
        
        145
        +	default:

      
        
        146
        +		fmt.Fprintf(&p.buf, "; unknown entry %T", e)

      
        
        147
        +	}

      
        
        148
        +	p.buf.WriteByte('\n')

      
        
        149
        +}

      
        
        150
        +

      
        
        151
        +func (p *printer) writeSpaces(n int) {

      
        
        152
        +	for range n {

      
        
        153
        +		p.buf.WriteByte(' ')

      
        
        154
        +	}

      
        
        155
        +}

      
        
        156
        +

      
        
        157
        +func isCommentChar(b byte) bool {

      
        
        158
        +	return b == ';' || b == '#' || b == '%' || b == '*'

      
        
        159
        +}

      
        
        160
        +

      
        
        161
        +func (p *printer) writeComment(c *ast.Comment) {

      
        
        162
        +	if c != nil && c.Text != "" {

      
        
        163
        +		p.buf.WriteByte(c.Marker)

      
        
        164
        +		if !isCommentChar(c.Text[0]) {

      
        
        165
        +			p.buf.WriteByte(' ')

      
        
        166
        +		}

      
        
        167
        +		p.buf.WriteString(c.Text)

      
        
        168
        +	}

      
        
        169
        +}

      
        
        170
        +

      
        
        171
        +func (p *printer) writeInlineComment(c *ast.Comment) {

      
        
        172
        +	if c != nil && c.Text != "" {

      
        
        173
        +		p.buf.WriteByte(' ')

      
        
        174
        +		p.buf.WriteByte(c.Marker)

      
        
        175
        +		if !isCommentChar(c.Text[0]) {

      
        
        176
        +			p.buf.WriteByte(' ')

      
        
        177
        +		}

      
        
        178
        +		p.buf.WriteString(c.Text)

      
        
        179
        +	}

      
        
        180
        +}

      
        
        181
        +

      
        
        182
        +func spaces(n int) string {

      
        
        183
        +	b := make([]byte, n)

      
        
        184
        +	for i := range b {

      
        
        185
        +		b[i] = ' '

      
        
        186
        +	}

      
        
        187
        +	return string(b)

      
        
        188
        +}

      
A journal/printer/printer_test.go
···
        
        1
        +package printer

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"strings"

      
        
        5
        +	"testing"

      
        
        6
        +

      
        
        7
        +	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        
        8
        +	"olexsmir.xyz/clerk/journal"

      
        
        9
        +)

      
        
        10
        +

      
        
        11
        +func TestRoundTrip(t *testing.T) {

      
        
        12
        +	tests := []string{"entries", "directives", "sample"}

      
        
        13
        +	for _, tname := range tests {

      
        
        14
        +		t.Run(tname, func(t *testing.T) {

      
        
        15
        +			inp := golden.Load(t, tname)

      
        
        16
        +

      
        
        17
        +			pf, err := journal.NewLoader().LoadBytes(tname+".journal", inp)

      
        
        18
        +			if err != nil {

      
        
        19
        +				t.Fatal(err)

      
        
        20
        +			}

      
        
        21
        +			var b strings.Builder

      
        
        22
        +			if err := defaultConfig.Fprint(&b, pf.Ast); err != nil {

      
        
        23
        +				t.Fatal(err)

      
        
        24
        +			}

      
        
        25
        +

      
        
        26
        +			golden.AssertInput(t, b.String(), tname)

      
        
        27
        +		})

      
        
        28
        +	}

      
        
        29
        +}

      
        
        30
        +

      
        
        31
        +func BenchmarkPrinter(b *testing.B) {

      
        
        32
        +	for _, tname := range []string{"entries", "directives", "sample"} {

      
        
        33
        +		b.Run(tname, func(b *testing.B) {

      
        
        34
        +			b.ReportAllocs()

      
        
        35
        +			inp := golden.Load(b, tname)

      
        
        36
        +			pf, err := journal.NewLoader().LoadBytes(tname+".journal", inp)

      
        
        37
        +			if err != nil {

      
        
        38
        +				b.Fatal(err)

      
        
        39
        +			}

      
        
        40
        +			b.ResetTimer()

      
        
        41
        +			for i := 0; i < b.N; i++ {

      
        
        42
        +				var buf strings.Builder

      
        
        43
        +				if err := defaultConfig.Fprint(&buf, pf.Ast); err != nil {

      
        
        44
        +					b.Fatal(err)

      
        
        45
        +				}

      
        
        46
        +			}

      
        
        47
        +		})

      
        
        48
        +	}

      
        
        49
        +}

      
        
        50
        +

      
        
        51
        +func TestRoundTrip_WithConfig(t *testing.T) {

      
        
        52
        +	tests := map[string]*Config{

      
        
        53
        +		"align_right":      {AlignStyle: AlignRight, AlignColumn: 50},

      
        
        54
        +		"align_tab":        {AlignStyle: AlignTab, AlignColumn: 50},

      
        
        55
        +		"commodity_before": {CommodityPos: CommodityBefore},

      
        
        56
        +		"tab_indent":       {TabIndent: true},

      
        
        57
        +		"indent_width":     {IndentWidth: 4},

      
        
        58
        +	}

      
        
        59
        +	for tname, tt := range tests {

      
        
        60
        +		t.Run(tname, func(t *testing.T) {

      
        
        61
        +			inp := golden.Load(t, tname)

      
        
        62
        +			pf, err := journal.NewLoader().LoadBytes(tname+".journal", inp)

      
        
        63
        +			if err != nil {

      
        
        64
        +				t.Fatal(err)

      
        
        65
        +			}

      
        
        66
        +			var b strings.Builder

      
        
        67
        +			if err := tt.Fprint(&b, pf.Ast); err != nil {

      
        
        68
        +				t.Fatal(err)

      
        
        69
        +			}

      
        
        70
        +

      
        
        71
        +			golden.AssertInput(t, b.String(), tname)

      
        
        72
        +		})

      
        
        73
        +	}

      
        
        74
        +}

      
        
        75
        +

      
        
        76
        +func BenchmarkPrinter_Config(b *testing.B) {

      
        
        77
        +	configs := map[string]*Config{

      
        
        78
        +		"default":          defaultConfig,

      
        
        79
        +		"align_right":      {AlignStyle: AlignRight, AlignColumn: 50},

      
        
        80
        +		"align_tab":        {AlignStyle: AlignTab, AlignColumn: 50},

      
        
        81
        +		"commodity_before": {CommodityPos: CommodityBefore},

      
        
        82
        +		"tab_indent":       {TabIndent: true},

      
        
        83
        +		"indent_width":     {IndentWidth: 4},

      
        
        84
        +	}

      
        
        85
        +	inp := golden.Load(b, "entries")

      
        
        86
        +	pf, err := journal.NewLoader().LoadBytes("entries.journal", inp)

      
        
        87
        +	if err != nil {

      
        
        88
        +		b.Fatal(err)

      
        
        89
        +	}

      
        
        90
        +	for name, cfg := range configs {

      
        
        91
        +		b.Run(name, func(b *testing.B) {

      
        
        92
        +			b.ReportAllocs()

      
        
        93
        +			b.ResetTimer()

      
        
        94
        +			for i := 0; i < b.N; i++ {

      
        
        95
        +				var buf strings.Builder

      
        
        96
        +				if err := cfg.Fprint(&buf, pf.Ast); err != nil {

      
        
        97
        +					b.Fatal(err)

      
        
        98
        +				}

      
        
        99
        +			}

      
        
        100
        +		})

      
        
        101
        +	}

      
        
        102
        +}

      
A journal/printer/testdata/align_right.golden
···
        
        1
        +2026-01-15 * Test restaurant

      
        
        2
        +  Expenses:FoodAndDrinks                            50.00$

      
        
        3
        +  Assets:Cash                                       -50.00$

      
        
        4
        +

      
        
        5
        +2026-01-16 another entry

      
        
        6
        +  Expenses:Food                                     30.00$

      
        
        7
        +  Assets:Cash                                       -30.00$

      
A journal/printer/testdata/align_right.input
···
        
        1
        +2026-01-15 * Test restaurant

      
        
        2
        +  Expenses:FoodAndDrinks  50.00$

      
        
        3
        +  Assets:Cash  -50.00$

      
        
        4
        +

      
        
        5
        +2026-01-16 another entry

      
        
        6
        +  Expenses:Food  30.00$

      
        
        7
        +  Assets:Cash  -30.00$

      
A journal/printer/testdata/align_tab.golden
···
        
        1
        +2026-01-15 * Test restaurant

      
        
        2
        +  Expenses:FoodAndDrinks  50.00$

      
        
        3
        +  Assets:Cash             -50.00$

      
        
        4
        +

      
        
        5
        +2026-01-16 Another entry with longer account

      
        
        6
        +  Expenses:SuperMarket  120.50$

      
        
        7
        +  Assets:Cash           -120.50$

      
        
        8
        +

      
        
        9
        +2026-01-17 Short accounts

      
        
        10
        +  a  10.00$

      
        
        11
        +  b  -10.00$

      
A journal/printer/testdata/align_tab.input
···
        
        1
        +2026-01-15 * Test restaurant

      
        
        2
        +  Expenses:FoodAndDrinks  50.00$

      
        
        3
        +  Assets:Cash  -50.00$

      
        
        4
        +

      
        
        5
        +2026-01-16 Another entry with longer account

      
        
        6
        +  Expenses:SuperMarket  120.50$

      
        
        7
        +  Assets:Cash  -120.50$

      
        
        8
        +

      
        
        9
        +2026-01-17 Short accounts

      
        
        10
        +  a  10.00$

      
        
        11
        +  b  -10.00$

      
A journal/printer/testdata/basic_twospaces.golden
···
        
        1
        +2026-01-15 * Test restaurant

      
        
        2
        +  ; header comment on transaction (before payee)

      
        
        3
        +  Expenses:Food  50.00$

      
        
        4
        +  Assets:Cash  -50.00$

      
        
        5
        +  ; inline comment on posting

      
        
        6
        +

      
        
        7
        +; A simple comment line

      
        
        8
        +

      
        
        9
        +2026-01-16 another entry

      
        
        10
        +  Expenses:Food  30.00$

      
        
        11
        +  Assets:Cash  -30.00$

      
A journal/printer/testdata/commodity_before.golden
···
        
        1
        +2026-01-15 * Groceries

      
        
        2
        +  Expenses:Food  $50.00

      
        
        3
        +  Assets:Cash  $-50.00

      
        
        4
        +

      
        
        5
        +2026-01-16 Salary

      
        
        6
        +  Assets:Cash  $2000.00

      
        
        7
        +  Income:Salary  $-2000.00

      
A journal/printer/testdata/commodity_before.input
···
        
        1
        +2026-01-15 * Groceries

      
        
        2
        +  Expenses:Food  50.00$

      
        
        3
        +  Assets:Cash  -50.00$

      
        
        4
        +

      
        
        5
        +2026-01-16 Salary

      
        
        6
        +  Assets:Cash  2000.00$

      
        
        7
        +  Income:Salary  -2000.00$

      
A journal/printer/testdata/directives.golden
···
        
        1
        +; Directives

      
        
        2
        +account Assets:Cash ; cash account

      
        
        3
        +commodity $

      
        
        4
        +include 

      
        
        5
        +alias Assets:Checking = Assets:Cash

      
        
        6
        +payee "Test Payee"

      
        
        7
        +tag 

      
        
        8
        +year 2026

      
        
        9
        +decimal-mark ,

      
        
        10
        +

      
        
        11
        +; Market price

      
        
        12
        +P 2026-01-15 12:00:00 GOOG 150.00$

      
        
        13
        +

      
        
        14
        +; Conversion

      
        
        15
        +C 100.00$ = 85.00€ ; test comment

      
        
        16
        +

      
        
        17
        +; Default commodity

      
        
        18
        +D 1,000.00$

      
        
        19
        +

      
        
        20
        +; Apply/end

      
        
        21
        +apply account Expenses

      
        
        22
        +end

      
        
        23
        +

      
        
        24
        +; Comment block

      
        
        25
        +comment

      
        
        26
        +Some notes about the file

      
        
        27
        +end

      
A journal/printer/testdata/directives.input
···
        
        1
        +; Directives

      
        
        2
        +account Assets:Cash ; cash account

      
        
        3
        +commodity $

      
        
        4
        +include "other.journal"

      
        
        5
        +alias Assets:Checking = Assets:Cash

      
        
        6
        +payee "Test Payee"

      
        
        7
        +tag "important"

      
        
        8
        +year 2026

      
        
        9
        +decimal-mark ,

      
        
        10
        +

      
        
        11
        +; Market price

      
        
        12
        +P 2026/01/15 12:00:00 GOOG $150.00

      
        
        13
        +

      
        
        14
        +; Conversion

      
        
        15
        +C 100.00$ = 85.00€  ; test comment

      
        
        16
        +

      
        
        17
        +; Default commodity

      
        
        18
        +D $1,000.00

      
        
        19
        +

      
        
        20
        +; Apply/end

      
        
        21
        +apply account Expenses

      
        
        22
        +end

      
        
        23
        +

      
        
        24
        +; Comment block

      
        
        25
        +comment

      
        
        26
        +Some notes about the file

      
        
        27
        +end comment

      
A journal/printer/testdata/entries.golden
···
        
        1
        +; i am a comment

      
        
        2
        +;; i am a comment

      
        
        3
        +# i am a comment

      
        
        4
        +* i am a comment

      
        
        5
        +**** i'm a fat comment

      
        
        6
        +% and i am a comment

      
        
        7
        +

      
        
        8
        +2026-01-15 * Test restaurant

      
        
        9
        +  ; header comment on transaction (before payee)

      
        
        10
        +  Expenses:Food  50.00$

      
        
        11
        +  Assets:Cash  -50.00$

      
        
        12
        +  ; inline comment on posting

      
        
        13
        +

      
        
        14
        +; A simple comment line

      
        
        15
        +

      
        
        16
        +2026-01-16 another entry

      
        
        17
        +  Expenses:Food  30.00$

      
        
        18
        +  Assets:Cash  -30.00$

      
        
        19
        +

      
        
        20
        +~ monthly

      
        
        21
        +  Expenses:Food  100.00$

      
        
        22
        +  Assets:Cash  -100.00$

      
        
        23
        +

      
        
        24
        += account:Expenses

      
        
        25
        +  Expenses:Food  100.00$

      
        
        26
        +  Assets:Cash  -100.00$

      
        
        27
        +

      
        
        28
        +2026-01-15 * (CODE) Test with code and note | Some note

      
        
        29
        +  Expenses:Food  100.00$

      
        
        30
        +  Assets:Cash  -100.00$

      
        
        31
        +

      
        
        32
        +2026-01-15=2026-01-20 * Virtual posting

      
        
        33
        +  (Expenses:Food)  100.00$

      
        
        34
        +  [Assets:Cash]  -100.00$

      
        
        35
        +

      
        
        36
        +2026-01-15 * Cost test

      
        
        37
        +  Expenses:Food  100.00$ @ 1.50$

      
        
        38
        +  Assets:Cash  -100.00$ @@ 1.50$

      
        
        39
        +  ; continuation comment

      
        
        40
        +

      
        
        41
        +2026-02-01 * Balance assertion test

      
        
        42
        +  Expenses:Food  50.00$ = 500.00$

      
        
        43
        +  Assets:Cash  -50.00$ == 500.00$

      
        
        44
        +  Assets:Total  === 0.00$

      
        
        45
        +

      
        
        46
        +2026-03-01 Multi-posting

      
        
        47
        +  Expenses:Food  30.00$

      
        
        48
        +  Expenses:Dining  20.00$

      
        
        49
        +  Assets:Cash  -50.00$

      
        
        50
        +

      
        
        51
        +0000-01-01 test

      
        
        52
        +  a  123.00$

      
        
        53
        +  b

      
        
        54
        +

      
        
        55
        +2026-01-15 * Comments test

      
        
        56
        +  ; header comment

      
        
        57
        +  Expenses:Food  50.00$

      
        
        58
        +  Assets:Cash  -50.00$

      
        
        59
        +  ; inline comment

      
A journal/printer/testdata/entries.input
···
        
        1
        +; i am a comment

      
        
        2
        +;; i am a comment

      
        
        3
        +# i am a comment

      
        
        4
        +* i am a comment

      
        
        5
        +**** i'm a fat comment

      
        
        6
        +% and i am a comment

      
        
        7
        +

      
        
        8
        +2026/01/15 * Test restaurant

      
        
        9
        +    ; header comment on transaction (before payee)

      
        
        10
        +    Expenses:Food    $50.00

      
        
        11
        +    Assets:Cash     $-50.00

      
        
        12
        +    ; inline comment on posting

      
        
        13
        +

      
        
        14
        +; A simple comment line

      
        
        15
        +

      
        
        16
        +2026/01/16 another entry

      
        
        17
        +    Expenses:Food    $30.00

      
        
        18
        +    Assets:Cash     $-30.00

      
        
        19
        +

      
        
        20
        +~ monthly

      
        
        21
        +    Expenses:Food    $100.00

      
        
        22
        +    Assets:Cash     $-100.00

      
        
        23
        +

      
        
        24
        += account:Expenses

      
        
        25
        +    Expenses:Food    $100.00

      
        
        26
        +    Assets:Cash     $-100.00

      
        
        27
        +

      
        
        28
        +2026/01/15 * (CODE) Test with code and note | Some note

      
        
        29
        +    Expenses:Food    $100

      
        
        30
        +    Assets:Cash     $-100

      
        
        31
        +

      
        
        32
        +2026/01/15=2026/01/20 * Virtual posting

      
        
        33
        +    (Expenses:Food)    $100.00

      
        
        34
        +    [Assets:Cash]     $-100.00

      
        
        35
        +

      
        
        36
        +2026/01/15 * Cost test

      
        
        37
        +    Expenses:Food  $100.00 @ $1.50

      
        
        38
        +    Assets:Cash    $-100.00 @@ $1.50

      
        
        39
        +    ; continuation comment

      
        
        40
        +

      
        
        41
        +2026/02/01 * Balance assertion test

      
        
        42
        +    Expenses:Food  $50.00 = $500.00

      
        
        43
        +    Assets:Cash   $-50.00 == $500.00

      
        
        44
        +    Assets:Total        === $0.00

      
        
        45
        +

      
        
        46
        +2026/03/01 Multi-posting

      
        
        47
        +    Expenses:Food    $30.00

      
        
        48
        +    Expenses:Dining  $20.00

      
        
        49
        +    Assets:Cash     $-50.00

      
        
        50
        +

      
        
        51
        +01-01 test

      
        
        52
        +    a  $123.00

      
        
        53
        +    b

      
        
        54
        +

      
        
        55
        +2026/01/15 * Comments test

      
        
        56
        +    ; header comment

      
        
        57
        +    Expenses:Food    $50.00

      
        
        58
        +    Assets:Cash     $-50.00

      
        
        59
        +    ; inline comment

      
A journal/printer/testdata/indent_width.golden
···
        
        1
        +2026-01-15 * Test restaurant

      
        
        2
        +    Expenses:Food  50.00$

      
        
        3
        +    Assets:Cash  -50.00$

      
        
        4
        +

      
        
        5
        +2026-01-16 Another entry

      
        
        6
        +    Expenses:Food  30.00$

      
        
        7
        +    Assets:Cash  -30.00$

      
A journal/printer/testdata/indent_width.input
···
        
        1
        +2026-01-15 * Test restaurant

      
        
        2
        +    Expenses:Food  50.00$

      
        
        3
        +    Assets:Cash  -50.00$

      
        
        4
        +

      
        
        5
        +2026-01-16 Another entry

      
        
        6
        +    Expenses:Food  30.00$

      
        
        7
        +    Assets:Cash  -30.00$

      
A journal/printer/testdata/sample.golden
···
        
        1
        +; accounts {{{

      
        
        2
        +account assets

      
        
        3
        +account assets:bank

      
        
        4
        +account assets:cash

      
        
        5
        +account equity

      
        
        6
        +account income

      
        
        7
        +account income:salary

      
        
        8
        +account income:invenstment

      
        
        9
        +account expenses

      
        
        10
        +account expenses:education

      
        
        11
        +; }}}

      
        
        12
        +; 01 Jan {{{

      
        
        13
        +2026-01-01 opening balances

      
        
        14
        +  assets:bank  123.00 USD

      
        
        15
        +  assets:cash  1234.12 USD

      
        
        16
        +  equity:open

      
        
        17
        +

      
        
        18
        +2026-01-04 selary

      
        
        19
        +  income:salary  543.22$

      
        
        20
        +  assets:bank

      
        
        21
        +; }}}

      
        
        22
        +; 02 Feb {{{

      
        
        23
        +2026-02-01 internet

      
        
        24
        +  expenses:utilities  350.00 UAH

      
        
        25
        +  assets:bank

      
        
        26
        +

      
        
        27
        +2026-02-10 * something | 2 months of *income*

      
        
        28
        +  assets:bank  1.50 USD ; jan

      
        
        29
        +  assets:bank  1.00 USD ; feb

      
        
        30
        +  income:invenstment

      
A journal/printer/testdata/sample.input
···
        
        1
        +; accounts {{{

      
        
        2
        +account assets

      
        
        3
        +account assets:bank

      
        
        4
        +account assets:cash

      
        
        5
        +account equity

      
        
        6
        +account income

      
        
        7
        +account income:salary

      
        
        8
        +account income:invenstment

      
        
        9
        +account expenses

      
        
        10
        +account expenses:education

      
        
        11
        +; }}}

      
        
        12
        +; 01 Jan {{{

      
        
        13
        +2026-01-01 opening balances

      
        
        14
        +  assets:bank  123.00 USD

      
        
        15
        +  assets:cash  1234.12 USD

      
        
        16
        +  equity:open

      
        
        17
        +

      
        
        18
        +2026-01-04 selary

      
        
        19
        +  income:salary  $543.22

      
        
        20
        +  assets:bank

      
        
        21
        +; }}}

      
        
        22
        +; 02 Feb {{{

      
        
        23
        +2026-02-01 internet

      
        
        24
        +  expenses:utilities  350 UAH

      
        
        25
        +  assets:bank

      
        
        26
        +

      
        
        27
        +2026-02-10 * something | 2 months of *income*

      
        
        28
        +    assets:bank  1.50 USD ; jan

      
        
        29
        +    assets:bank  USD 1 ; feb

      
        
        30
        +    income:invenstment

      
A journal/printer/testdata/tab_indent.golden
···
        
        1
        +2026-01-15 * Test restaurant

      
        
        2
        +	Expenses:Food  50.00$

      
        
        3
        +	Assets:Cash  -50.00$

      
        
        4
        +

      
        
        5
        +2026-01-16 Another entry

      
        
        6
        +	Expenses:Food  30.00$

      
        
        7
        +	Assets:Cash  -30.00$

      
A journal/printer/testdata/tab_indent.input
···
        
        1
        +2026-01-15 * Test restaurant

      
        
        2
        +  Expenses:Food  50.00$

      
        
        3
        +  Assets:Cash  -50.00$

      
        
        4
        +

      
        
        5
        +2026-01-16 Another entry

      
        
        6
        +  Expenses:Food  30.00$

      
        
        7
        +  Assets:Cash  -30.00$

      
A journal/printer/transaction.go
···
        
        1
        +package printer

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"olexsmir.xyz/clerk/journal/ast"

      
        
        5
        +)

      
        
        6
        +

      
        
        7
        +func (p *printer) writeTransaction(t *ast.Transaction) {

      
        
        8
        +	// date

      
        
        9
        +	p.writeDate(t.Date)

      
        
        10
        +

      
        
        11
        +	// second date

      
        
        12
        +	if t.SecondDate != nil {

      
        
        13
        +		p.buf.WriteByte('=')

      
        
        14
        +		p.writeDate(*t.SecondDate)

      
        
        15
        +	}

      
        
        16
        +

      
        
        17
        +	// status

      
        
        18
        +	if t.Status != nil && t.Status.Value != ast.StatusNone {

      
        
        19
        +		p.buf.WriteByte(' ')

      
        
        20
        +		p.buf.WriteString(t.Status.Value.String())

      
        
        21
        +	}

      
        
        22
        +

      
        
        23
        +	// code

      
        
        24
        +	if t.Code != nil && *t.Code != "" {

      
        
        25
        +		p.buf.WriteString(" (")

      
        
        26
        +		p.buf.WriteString(*t.Code)

      
        
        27
        +		p.buf.WriteByte(')')

      
        
        28
        +	}

      
        
        29
        +

      
        
        30
        +	// payee

      
        
        31
        +	if t.Payee != nil && t.Payee.Name != "" {

      
        
        32
        +		p.buf.WriteByte(' ')

      
        
        33
        +		p.buf.WriteString(t.Payee.Name)

      
        
        34
        +	}

      
        
        35
        +

      
        
        36
        +	// note

      
        
        37
        +	if t.Note != nil && *t.Note != "" {

      
        
        38
        +		p.buf.WriteString(" | ")

      
        
        39
        +		p.buf.WriteString(*t.Note)

      
        
        40
        +	}

      
        
        41
        +

      
        
        42
        +	p.writeComment(t.Comment)

      
        
        43
        +	p.buf.WriteByte('\n')

      
        
        44
        +

      
        
        45
        +	// header comments (between transaction line and first posting)

      
        
        46
        +	for _, c := range t.HeaderComments {

      
        
        47
        +		p.buf.WriteString(p.indent)

      
        
        48
        +		p.writeComment(c)

      
        
        49
        +		p.buf.WriteByte('\n')

      
        
        50
        +	}

      
        
        51
        +

      
        
        52
        +	// postings

      
        
        53
        +	p.writePostings(t.Postings)

      
        
        54
        +}

      
        
        55
        +

      
        
        56
        +func (p *printer) writePeriodicTransaction(t *ast.PeriodicTransaction) {

      
        
        57
        +	p.buf.WriteByte('~')

      
        
        58
        +

      
        
        59
        +	// period

      
        
        60
        +	if t.Period.Raw != "" {

      
        
        61
        +		p.buf.WriteByte(' ')

      
        
        62
        +		p.buf.WriteString(t.Period.Raw)

      
        
        63
        +	}

      
        
        64
        +

      
        
        65
        +	// status

      
        
        66
        +	if t.Status != nil && t.Status.Value != ast.StatusNone {

      
        
        67
        +		p.buf.WriteByte(' ')

      
        
        68
        +		p.buf.WriteString(t.Status.Value.String())

      
        
        69
        +	}

      
        
        70
        +

      
        
        71
        +	// code

      
        
        72
        +	if t.Code != nil && *t.Code != "" {

      
        
        73
        +		p.buf.WriteString(" (")

      
        
        74
        +		p.buf.WriteString(*t.Code)

      
        
        75
        +		p.buf.WriteByte(')')

      
        
        76
        +	}

      
        
        77
        +

      
        
        78
        +	// description

      
        
        79
        +	if t.Description != nil && *t.Description != "" {

      
        
        80
        +		p.buf.WriteByte(' ')

      
        
        81
        +		p.buf.WriteString(*t.Description)

      
        
        82
        +	}

      
        
        83
        +

      
        
        84
        +	// comment

      
        
        85
        +	p.writeInlineComment(t.Comment)

      
        
        86
        +	p.buf.WriteByte('\n')

      
        
        87
        +

      
        
        88
        +	// header comments (between periodic line and first posting)

      
        
        89
        +	for _, c := range t.HeaderComments {

      
        
        90
        +		p.buf.WriteString(p.indent)

      
        
        91
        +		p.writeComment(c)

      
        
        92
        +		p.buf.WriteByte('\n')

      
        
        93
        +	}

      
        
        94
        +

      
        
        95
        +	// postings

      
        
        96
        +	p.writePostings(t.Postings)

      
        
        97
        +}

      
        
        98
        +

      
        
        99
        +func (p *printer) writeAutomatedTransaction(t *ast.AutomatedTransaction) {

      
        
        100
        +	p.buf.WriteByte('=')

      
        
        101
        +

      
        
        102
        +	// expression

      
        
        103
        +	if t.Expr != "" {

      
        
        104
        +		p.buf.WriteByte(' ')

      
        
        105
        +		p.buf.WriteString(t.Expr)

      
        
        106
        +	}

      
        
        107
        +

      
        
        108
        +	// comment

      
        
        109
        +	if t.Comment != nil && t.Comment.Text != "" {

      
        
        110
        +		p.buf.WriteString(" ; ")

      
        
        111
        +		p.buf.WriteString(t.Comment.Text)

      
        
        112
        +	}

      
        
        113
        +

      
        
        114
        +	p.buf.WriteByte('\n')

      
        
        115
        +

      
        
        116
        +	// header comments (between auto line and first posting)

      
        
        117
        +	for _, c := range t.HeaderComments {

      
        
        118
        +		p.buf.WriteString(p.indent)

      
        
        119
        +		p.writeComment(c)

      
        
        120
        +		p.buf.WriteByte('\n')

      
        
        121
        +	}

      
        
        122
        +

      
        
        123
        +	// postings

      
        
        124
        +	p.writePostings(t.Postings)

      
        
        125
        +}

      
        
        126
        +

      
        
        127
        +// writeDate writes date duh.

      
        
        128
        +// TODO: support dates like '02/12'

      
        
        129
        +func (p *printer) writeDate(d ast.Date) {

      
        
        130
        +	p.writeInt(d.Year, 4)

      
        
        131
        +	p.buf.WriteByte('-')

      
        
        132
        +	p.writeInt(int(d.Month), 2)

      
        
        133
        +	p.buf.WriteByte('-')

      
        
        134
        +	p.writeInt(int(d.Day), 2)

      
        
        135
        +}

      
        
        136
        +

      
        
        137
        +func (p *printer) writeTime(t *ast.Time) {

      
        
        138
        +	if t != nil {

      
        
        139
        +		p.buf.WriteByte(' ')

      
        
        140
        +		p.writeInt(t.Hour, 2)

      
        
        141
        +		p.buf.WriteByte(':')

      
        
        142
        +		p.writeInt(t.Minute, 2)

      
        
        143
        +		p.buf.WriteByte(':')

      
        
        144
        +		p.writeInt(t.Second, 2)

      
        
        145
        +	}

      
        
        146
        +}

      
        
        147
        +

      
        
        148
        +// writeInt writes an integer left-padded to the given number of digits.

      
        
        149
        +func (p *printer) writeInt(n int, digits int) {

      
        
        150
        +	var b [10]byte

      
        
        151
        +	pos := len(b)

      
        
        152
        +	for range digits {

      
        
        153
        +		pos--

      
        
        154
        +		b[pos] = '0' + byte(n%10)

      
        
        155
        +		n /= 10

      
        
        156
        +	}

      
        
        157
        +	p.buf.Write(b[pos:])

      
        
        158
        +}

      
A journal/printer/transaction_postings.go
···
        
        1
        +package printer

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +	"strings"

      
        
        6
        +	"text/tabwriter"

      
        
        7
        +

      
        
        8
        +	"olexsmir.xyz/clerk/journal/ast"

      
        
        9
        +)

      
        
        10
        +

      
        
        11
        +func (p *printer) writePostings(postings []*ast.Posting) {

      
        
        12
        +	if len(postings) == 0 {

      
        
        13
        +		return

      
        
        14
        +	}

      
        
        15
        +

      
        
        16
        +	maxAcct := measureTxAccts(postings)

      
        
        17
        +	if p.cfg.AlignStyle == AlignTwoSpaces {

      
        
        18
        +		p.writePostingsTwoSpaces(postings, maxAcct)

      
        
        19
        +	} else {

      
        
        20
        +		p.writePostingsTabbed(postings, maxAcct)

      
        
        21
        +	}

      
        
        22
        +}

      
        
        23
        +

      
        
        24
        +func (p *printer) writePostingsTwoSpaces(postings []*ast.Posting, maxAcct int) {

      
        
        25
        +	for _, pt := range postings {

      
        
        26
        +		p.writePostingLine(pt, maxAcct)

      
        
        27
        +		p.buf.WriteByte('\n')

      
        
        28
        +		for _, c := range pt.Comments {

      
        
        29
        +			p.buf.WriteString(p.indent)

      
        
        30
        +			p.writeComment(&c)

      
        
        31
        +			p.buf.WriteByte('\n')

      
        
        32
        +		}

      
        
        33
        +	}

      
        
        34
        +}

      
        
        35
        +

      
        
        36
        +func (p *printer) writePostingsTabbed(postings []*ast.Posting, maxAcct int) {

      
        
        37
        +	var tmp strings.Builder

      
        
        38
        +	tw := tabwriter.NewWriter(&tmp, 0, 0, 2, ' ', tabwriter.StripEscape)

      
        
        39
        +

      
        
        40
        +	lp := &printer{cfg: p.cfg, indent: p.indent}

      
        
        41
        +	for _, pt := range postings {

      
        
        42
        +		lp.buf.Reset()

      
        
        43
        +		lp.writePostingLine(pt, maxAcct)

      
        
        44
        +		fmt.Fprintln(tw, lp.buf.String())

      
        
        45
        +		for _, c := range pt.Comments {

      
        
        46
        +			lp.buf.Reset()

      
        
        47
        +			lp.buf.WriteString(lp.indent)

      
        
        48
        +			lp.writeComment(&c)

      
        
        49
        +			fmt.Fprintln(tw, lp.buf.String())

      
        
        50
        +		}

      
        
        51
        +	}

      
        
        52
        +	tw.Flush()

      
        
        53
        +

      
        
        54
        +	out := strings.TrimRight(tmp.String(), "\n")

      
        
        55
        +	if out == "" {

      
        
        56
        +		return

      
        
        57
        +	}

      
        
        58
        +	p.buf.WriteString(out)

      
        
        59
        +	p.buf.WriteByte('\n')

      
        
        60
        +}

      
        
        61
        +

      
        
        62
        +func (p *printer) writePostingLine(pt *ast.Posting, maxAcct int) {

      
        
        63
        +	p.buf.WriteString(p.indent)

      
        
        64
        +

      
        
        65
        +	if pt.Status != nil && pt.Status.Value != ast.StatusNone {

      
        
        66
        +		p.buf.WriteString(pt.Status.Value.String())

      
        
        67
        +		p.buf.WriteByte(' ')

      
        
        68
        +	}

      
        
        69
        +

      
        
        70
        +	acct := pt.Account.Name

      
        
        71
        +	switch pt.Type {

      
        
        72
        +	case ast.PostingVirtualUnbalanced:

      
        
        73
        +		acct = "(" + acct + ")"

      
        
        74
        +	case ast.PostingVirtualBalanced:

      
        
        75
        +		acct = "[" + acct + "]"

      
        
        76
        +	}

      
        
        77
        +	p.buf.WriteString(acct)

      
        
        78
        +

      
        
        79
        +	pos := p.cfg.CommodityPos

      
        
        80
        +

      
        
        81
        +	if pt.Amount != nil || pt.Balance != nil {

      
        
        82
        +		switch p.cfg.AlignStyle {

      
        
        83
        +		case AlignTwoSpaces:

      
        
        84
        +			p.buf.WriteString("  ")

      
        
        85
        +		case AlignRight:

      
        
        86
        +			current := len(p.indent) + len(acct)

      
        
        87
        +			if pt.Status != nil && pt.Status.Value != ast.StatusNone {

      
        
        88
        +				current++

      
        
        89
        +			}

      
        
        90
        +			if pad := p.cfg.AlignColumn - current; pad > 0 {

      
        
        91
        +				p.writeSpaces(pad)

      
        
        92
        +			}

      
        
        93
        +			p.buf.WriteByte('\v')

      
        
        94
        +		case AlignTab:

      
        
        95
        +			current := len(p.indent) + len(acct)

      
        
        96
        +			if pt.Status != nil && pt.Status.Value != ast.StatusNone {

      
        
        97
        +				current++

      
        
        98
        +			}

      
        
        99
        +			if pad := maxAcct + len(p.indent) - current; pad > 0 {

      
        
        100
        +				p.writeSpaces(pad)

      
        
        101
        +			}

      
        
        102
        +			p.buf.WriteByte('\v')

      
        
        103
        +		default:

      
        
        104
        +			panic("impossible AlignStyle state")

      
        
        105
        +		}

      
        
        106
        +	}

      
        
        107
        +

      
        
        108
        +	if pt.Amount != nil {

      
        
        109
        +		p.writeAmount(pt.Amount, pos)

      
        
        110
        +		if pt.Cost != nil {

      
        
        111
        +			p.writeCost(pt.Cost, pos)

      
        
        112
        +		}

      
        
        113
        +	}

      
        
        114
        +

      
        
        115
        +	if pt.Balance != nil {

      
        
        116
        +		if pt.Amount != nil {

      
        
        117
        +			p.buf.WriteByte(' ')

      
        
        118
        +		}

      
        
        119
        +		p.writeBalanceAssertion(pt.Balance, pos)

      
        
        120
        +	}

      
        
        121
        +

      
        
        122
        +	if pt.Comment != nil && pt.Comment.Text != "" {

      
        
        123
        +		if p.cfg.AlignStyle == AlignTwoSpaces {

      
        
        124
        +			p.buf.WriteString(" ")

      
        
        125
        +		} else {

      
        
        126
        +			p.buf.WriteByte('\v')

      
        
        127
        +		}

      
        
        128
        +		p.buf.WriteString("; ")

      
        
        129
        +		p.buf.WriteString(pt.Comment.Text)

      
        
        130
        +	}

      
        
        131
        +}

      
        
        132
        +

      
        
        133
        +func measureTxAccts(postings []*ast.Posting) int {

      
        
        134
        +	maxAcct := 0

      
        
        135
        +	for _, p := range postings {

      
        
        136
        +		n := len(p.Account.Name)

      
        
        137
        +		if p.Type == ast.PostingVirtualUnbalanced || p.Type == ast.PostingVirtualBalanced {

      
        
        138
        +			n += 2

      
        
        139
        +		}

      
        
        140
        +		if n > maxAcct {

      
        
        141
        +			maxAcct = n

      
        
        142
        +		}

      
        
        143
        +	}

      
        
        144
        +	return maxAcct

      
        
        145
        +}

      
M main.go
···
        12
        12
         	}

      
        13
        13
         

      
        14
        14
         	switch os.Args[1] {

      
        
        15
        +	case "fmt", "format":

      
        
        16
        +		runFormat(os.Args[2:])

      
        15
        17
         	case "tags":

      
        16
        18
         		runTags(os.Args[2:])

      
        17
        19
         	default:

      ···
        24
        26
         	fmt.Fprintf(os.Stderr, `Usage: clerk <command> [options]

      
        25
        27
         

      
        26
        28
         Commands:

      
        27
        
        -  tags   Generate ctags-compatibletag file.

      
        
        29
        +  tags       Generate ctags-compatible tag file

      
        
        30
        +  format     Format journal files

      
        28
        31
         `)

      
        29
        32
         }