all repos

clerk @ 6fdb9097048e212574439fb0da84d0c94aa7e01b

missing tooling for ledger/hledger
25 files changed, 1138 insertions(+), 3 deletions(-)
formatter
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-06-05 22:42:01 +0300
Authored at: 2026-06-02 19:48:08 +0300
Change ID: oxrnnkuuzpxsywpvknlmxqqnxpylrvzt
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
        +	write := fs.Bool("w", false, "Write result back to file instead of stdout")

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

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

      
        
        19
        +	fs.Usage = func() {

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

      
        
        21
        +		fs.PrintDefaults()

      
        
        22
        +	}

      
        
        23
        +	fs.Parse(args)

      
        
        24
        +

      
        
        25
        +	paths := fs.Args()

      
        
        26
        +

      
        
        27
        +	// Read from stdin if no paths given

      
        
        28
        +	if len(paths) == 0 {

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

      
        
        30
        +		if err != nil {

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

      
        
        32
        +			os.Exit(1)

      
        
        33
        +		}

      
        
        34
        +

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

      
        
        36
        +		if err != nil {

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

      
        
        38
        +			os.Exit(1)

      
        
        39
        +		}

      
        
        40
        +

      
        
        41
        +		var buf bytes.Buffer

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

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

      
        
        44
        +			os.Exit(1)

      
        
        45
        +		}

      
        
        46
        +

      
        
        47
        +		if *check {

      
        
        48
        +			if bytes.Equal(src, buf.Bytes()) {

      
        
        49
        +				os.Exit(0)

      
        
        50
        +			}

      
        
        51
        +			os.Exit(1)

      
        
        52
        +		}

      
        
        53
        +		os.Stdout.Write(buf.Bytes())

      
        
        54
        +		return

      
        
        55
        +	}

      
        
        56
        +

      
        
        57
        +	// Process each file

      
        
        58
        +	exitCode := 0

      
        
        59
        +	for _, path := range paths {

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

      
        
        61
        +		if err != nil {

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

      
        
        63
        +			exitCode = 1

      
        
        64
        +			continue

      
        
        65
        +		}

      
        
        66
        +		if info.IsDir() {

      
        
        67
        +			fmt.Fprintf(os.Stderr, "error: %s: can only format files, not directories\n", path)

      
        
        68
        +			exitCode = 1

      
        
        69
        +			continue

      
        
        70
        +		}

      
        
        71
        +

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

      
        
        73
        +		if err != nil {

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

      
        
        75
        +			exitCode = 1

      
        
        76
        +			continue

      
        
        77
        +		}

      
        
        78
        +

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

      
        
        80
        +		if err != nil {

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

      
        
        82
        +			exitCode = 1

      
        
        83
        +			continue

      
        
        84
        +		}

      
        
        85
        +

      
        
        86
        +		var buf bytes.Buffer

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

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

      
        
        89
        +			exitCode = 1

      
        
        90
        +			continue

      
        
        91
        +		}

      
        
        92
        +

      
        
        93
        +		formatted := buf.Bytes()

      
        
        94
        +

      
        
        95
        +		if *check {

      
        
        96
        +			if !bytes.Equal(src, formatted) {

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

      
        
        98
        +				exitCode = 1

      
        
        99
        +			}

      
        
        100
        +			continue

      
        
        101
        +		}

      
        
        102
        +

      
        
        103
        +		if *diff {

      
        
        104
        +			diffLines(path, src, formatted)

      
        
        105
        +			continue

      
        
        106
        +		}

      
        
        107
        +

      
        
        108
        +		if *write {

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

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

      
        
        111
        +				exitCode = 1

      
        
        112
        +			}

      
        
        113
        +		} else {

      
        
        114
        +			os.Stdout.Write(formatted)

      
        
        115
        +		}

      
        
        116
        +	}

      
        
        117
        +

      
        
        118
        +	os.Exit(exitCode)

      
        
        119
        +}

      
        
        120
        +

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

      
        
        122
        +	// simple diff display

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

      
        
        124
        +		return

      
        
        125
        +	}

      
        
        126
        +	fmt.Fprintf(os.Stderr, "--- %s\n+++ %s\n", path, path)

      
        
        127
        +	// TODO: proper line-by-line diff

      
        
        128
        +}

      
M internal/decimal/decimal.go
···
        11
        11
         	coeff *big.Int

      
        12
        12
         }

      
        13
        13
         

      
        
        14
        +// Coeff returns the unscaled coefficient (always non-nil for non-zero values).

      
        
        15
        +func (d Decimal) Coeff() *big.Int { return d.coeff }

      
        
        16
        +

      
        
        17
        +// Scale returns the scale (number of fractional digits).

      
        
        18
        +func (d Decimal) Scale() int { return d.scale }

      
        
        19
        +

      
        14
        20
         func FromInt(v int64) Decimal {

      
        15
        21
         	if v == 0 {

      
        16
        22
         		return Decimal{}

      
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
···
        17
        17
         	Payee          *Payee    // optional payee

      
        18
        18
         	Note           *string   // part after |

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

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

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

      
        21
        21
         	Postings       []*Posting

      
        22
        22
         	Span           token.Span

      
        23
        23
         }

      
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

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"strconv"

      
        
        5
        +

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

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

      
        
        8
        +)

      
        
        9
        +

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

      
        
        11
        +	if a == nil {

      
        
        12
        +		return

      
        
        13
        +	}

      
        
        14
        +	if a.IsExpr {

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

      
        
        16
        +		return

      
        
        17
        +	}

      
        
        18
        +

      
        
        19
        +	comm := a.Commodity

      
        
        20
        +

      
        
        21
        +	if comm == "" {

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

      
        
        23
        +		return

      
        
        24
        +	}

      
        
        25
        +

      
        
        26
        +	switch pos {

      
        
        27
        +	case CommodityBefore:

      
        
        28
        +		p.buf.WriteString(comm)

      
        
        29
        +		if a.HasSpace {

      
        
        30
        +			p.buf.WriteByte(' ')

      
        
        31
        +		}

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

      
        
        33
        +	case CommodityAfter:

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

      
        
        35
        +		if a.HasSpace {

      
        
        36
        +			p.buf.WriteByte(' ')

      
        
        37
        +		}

      
        
        38
        +		p.buf.WriteString(comm)

      
        
        39
        +	default:

      
        
        40
        +		panic("invalid CommodityPos value")

      
        
        41
        +	}

      
        
        42
        +}

      
        
        43
        +

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

      
        
        45
        +	if c == nil {

      
        
        46
        +		return

      
        
        47
        +	}

      
        
        48
        +	if c.IsTotal {

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

      
        
        50
        +	} else {

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

      
        
        52
        +	}

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

      
        
        54
        +}

      
        
        55
        +

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

      
        
        57
        +	if ba == nil {

      
        
        58
        +		return

      
        
        59
        +	}

      
        
        60
        +	p.buf.WriteByte(' ')

      
        
        61
        +	switch {

      
        
        62
        +	case ba.IsInclusive:

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

      
        
        64
        +	case ba.IsStrict:

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

      
        
        66
        +	default:

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

      
        
        68
        +	}

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

      
        
        70
        +}

      
        
        71
        +

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

      
        
        73
        +	if d.IsZero() {

      
        
        74
        +		p.writeZero(fmt, forcePrec)

      
        
        75
        +		return

      
        
        76
        +	}

      
        
        77
        +

      
        
        78
        +	coeff := d.Coeff()

      
        
        79
        +	if coeff == nil || coeff.Sign() == 0 {

      
        
        80
        +		p.writeZero(fmt, forcePrec)

      
        
        81
        +		return

      
        
        82
        +	}

      
        
        83
        +

      
        
        84
        +	offset := d.Scale()

      
        
        85
        +	neg := coeff.Sign() < 0

      
        
        86
        +

      
        
        87
        +	raw := coeff.String()

      
        
        88
        +	if neg {

      
        
        89
        +		raw = raw[1:]

      
        
        90
        +	}

      
        
        91
        +

      
        
        92
        +	decSep := byte('.')

      
        
        93
        +	if fmt.Decimal != 0 {

      
        
        94
        +		decSep = fmt.Decimal

      
        
        95
        +	}

      
        
        96
        +

      
        
        97
        +	if offset >= len(raw) {

      
        
        98
        +		zeros := offset - len(raw) + 1

      
        
        99
        +		if neg {

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

      
        
        101
        +		}

      
        
        102
        +		p.buf.WriteByte('0')

      
        
        103
        +		p.buf.WriteByte(decSep)

      
        
        104
        +		for i := 0; i < zeros-1; i++ {

      
        
        105
        +			p.buf.WriteByte('0')

      
        
        106
        +		}

      
        
        107
        +		if len(raw) < forcePrec {

      
        
        108
        +			p.buf.WriteString(raw)

      
        
        109
        +			for i := len(raw); i < forcePrec; i++ {

      
        
        110
        +				p.buf.WriteByte('0')

      
        
        111
        +			}

      
        
        112
        +		} else {

      
        
        113
        +			p.buf.WriteString(raw[:forcePrec])

      
        
        114
        +		}

      
        
        115
        +		return

      
        
        116
        +	}

      
        
        117
        +

      
        
        118
        +	split := len(raw) - offset

      
        
        119
        +	intPart := raw[:split]

      
        
        120
        +	fracPart := raw[split:]

      
        
        121
        +

      
        
        122
        +	intPart = trimLeadingZeros(intPart)

      
        
        123
        +	if intPart == "" {

      
        
        124
        +		intPart = "0"

      
        
        125
        +	}

      
        
        126
        +

      
        
        127
        +	if neg {

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

      
        
        129
        +	}

      
        
        130
        +

      
        
        131
        +	if fmt.Thousands != 0 && len(intPart) > 3 {

      
        
        132
        +		p.writeThousands(intPart, fmt.Thousands)

      
        
        133
        +	} else {

      
        
        134
        +		p.buf.WriteString(intPart)

      
        
        135
        +	}

      
        
        136
        +

      
        
        137
        +	p.buf.WriteByte(decSep)

      
        
        138
        +

      
        
        139
        +	if len(fracPart) < forcePrec {

      
        
        140
        +		p.buf.WriteString(fracPart)

      
        
        141
        +		for i := len(fracPart); i < forcePrec; i++ {

      
        
        142
        +			p.buf.WriteByte('0')

      
        
        143
        +		}

      
        
        144
        +	} else {

      
        
        145
        +		p.buf.WriteString(fracPart[:forcePrec])

      
        
        146
        +	}

      
        
        147
        +}

      
        
        148
        +

      
        
        149
        +func (p *printer) writeZero(fmt ast.QuantityFormat, prec int) {

      
        
        150
        +	p.buf.WriteByte('0')

      
        
        151
        +	sep := "."

      
        
        152
        +	if fmt.Decimal != 0 {

      
        
        153
        +		sep = string(fmt.Decimal)

      
        
        154
        +	}

      
        
        155
        +	p.buf.WriteString(sep)

      
        
        156
        +	for range prec {

      
        
        157
        +		p.buf.WriteByte('0')

      
        
        158
        +	}

      
        
        159
        +}

      
        
        160
        +

      
        
        161
        +func (p *printer) writeThousands(s string, sep byte) {

      
        
        162
        +	n := len(s)

      
        
        163
        +	for i := range n {

      
        
        164
        +		if i > 0 && (n-i)%3 == 0 {

      
        
        165
        +			p.buf.WriteByte(sep)

      
        
        166
        +		}

      
        
        167
        +		p.buf.WriteByte(s[i])

      
        
        168
        +	}

      
        
        169
        +}

      
        
        170
        +

      
        
        171
        +func trimLeadingZeros(s string) string {

      
        
        172
        +	for i := 0; i < len(s); i++ {

      
        
        173
        +		if s[i] != '0' {

      
        
        174
        +			return s[i:]

      
        
        175
        +		}

      
        
        176
        +	}

      
        
        177
        +	return ""

      
        
        178
        +}

      
        
        179
        +

      
        
        180
        +func quoteString(s string) string {

      
        
        181
        +	needsQuote := false

      
        
        182
        +	for _, c := range s {

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

      
        
        184
        +			needsQuote = true

      
        
        185
        +			break

      
        
        186
        +		}

      
        
        187
        +	}

      
        
        188
        +	if !needsQuote {

      
        
        189
        +		return s

      
        
        190
        +	}

      
        
        191
        +	return strconv.Quote(s)

      
        
        192
        +}

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"strings"

      
        
        5
        +

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

      
        
        7
        +)

      
        
        8
        +

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

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

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

      
        
        12
        +	p.writeInlineComment(i.Comment)

      
        
        13
        +}

      
        
        14
        +

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

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

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

      
        
        18
        +	p.writeInlineComment(a.Comment)

      
        
        19
        +}

      
        
        20
        +

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

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

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

      
        
        24
        +	p.writeInlineComment(c.Comment)

      
        
        25
        +}

      
        
        26
        +

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

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

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

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

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

      
        
        32
        +	p.writeInlineComment(a.Comment)

      
        
        33
        +}

      
        
        34
        +

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

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

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

      
        
        38
        +	p.writeInlineComment(pd.Comment)

      
        
        39
        +}

      
        
        40
        +

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

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

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

      
        
        44
        +	p.writeInlineComment(t.Comment)

      
        
        45
        +}

      
        
        46
        +

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

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

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

      
        
        50
        +	p.writeInlineComment(y.Comment)

      
        
        51
        +}

      
        
        52
        +

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

      
        
        54
        +	p.buf.WriteString("decimal ")

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

      
        
        56
        +	p.writeInlineComment(d.Comment)

      
        
        57
        +}

      
        
        58
        +

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

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

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

      
        
        62
        +	p.writeInlineComment(d.Comment)

      
        
        63
        +}

      
        
        64
        +

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

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

      
        
        67
        +	p.writeAmount(&c.From, CommodityAfter) // TODO: support CommodityBefore

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

      
        
        69
        +	p.writeAmount(&c.To, CommodityAfter) // TODO: support CommodityBefore

      
        
        70
        +	p.writeInlineComment(c.Comment)

      
        
        71
        +}

      
        
        72
        +

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

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

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

      
        
        76
        +	if m.DateTime.Time != nil {

      
        
        77
        +		p.buf.WriteByte(' ')

      
        
        78
        +		p.writeInt(m.DateTime.Time.Hour, 2)

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

      
        
        80
        +		p.writeInt(m.DateTime.Time.Minute, 2)

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

      
        
        82
        +		p.writeInt(m.DateTime.Time.Second, 2)

      
        
        83
        +	}

      
        
        84
        +	p.buf.WriteByte(' ')

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

      
        
        86
        +	p.buf.WriteByte(' ')

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

      
        
        88
        +	p.writeInlineComment(m.Comment)

      
        
        89
        +}

      
        
        90
        +

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

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

      
        
        93
        +	if cb.Header != "" {

      
        
        94
        +		p.buf.WriteByte(' ')

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

      
        
        96
        +	}

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

      
        
        98
        +	if cb.Content != "" {

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

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

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

      
        
        102
        +		}

      
        
        103
        +	}

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

      
        
        105
        +	if cb.Header != "" {

      
        
        106
        +		p.buf.WriteByte(' ')

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

      
        
        108
        +	}

      
        
        109
        +}

      
        
        110
        +

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

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

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

      
        
        114
        +	p.writeInlineComment(a.Comment)

      
        
        115
        +}

      
        
        116
        +

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

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

      
        
        119
        +	if e.Expr != "" {

      
        
        120
        +		p.buf.WriteByte(' ')

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

      
        
        122
        +	}

      
        
        123
        +	p.writeInlineComment(e.Comment)

      
        
        124
        +}

      
A journal/printer/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
        +		}

      
        
        104
        +	}

      
        
        105
        +

      
        
        106
        +	if pt.Amount != nil {

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

      
        
        108
        +		if pt.Cost != nil {

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

      
        
        110
        +		}

      
        
        111
        +	}

      
        
        112
        +

      
        
        113
        +	if pt.Balance != nil {

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

      
        
        115
        +	}

      
        
        116
        +

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

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

      
        
        119
        +			p.buf.WriteString("  ")

      
        
        120
        +		} else {

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

      
        
        122
        +		}

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

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

      
        
        125
        +	}

      
        
        126
        +}

      
        
        127
        +

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

      
        
        129
        +	maxAcct := 0

      
        
        130
        +	for _, p := range postings {

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

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

      
        
        133
        +			n += 2

      
        
        134
        +		}

      
        
        135
        +		if n > maxAcct {

      
        
        136
        +			maxAcct = n

      
        
        137
        +		}

      
        
        138
        +	}

      
        
        139
        +	return maxAcct

      
        
        140
        +}

      
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
        +	CommentWidth int          // wrap standalone comments at this width

      
        
        34
        +	CommodityPos CommodityPos // where to place commodity

      
        
        35
        +}

      
        
        36
        +

      
        
        37
        +var defaultConfig = &Config{

      
        
        38
        +	TabIndent:    false,

      
        
        39
        +	IndentWidth:  2,

      
        
        40
        +	AlignStyle:   AlignTwoSpaces,

      
        
        41
        +	AlignColumn:  70,

      
        
        42
        +	CommentWidth: 90,

      
        
        43
        +	CommodityPos: CommodityAfter,

      
        
        44
        +}

      
        
        45
        +

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

      
        
        47
        +	if c.TabIndent {

      
        
        48
        +		return "\t"

      
        
        49
        +	}

      
        
        50
        +	n := 2

      
        
        51
        +	if c.IndentWidth > 0 {

      
        
        52
        +		n = c.IndentWidth

      
        
        53
        +	}

      
        
        54
        +	return spaces(n)

      
        
        55
        +}

      
        
        56
        +

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

      
        
        58
        +type printer struct {

      
        
        59
        +	buf    strings.Builder

      
        
        60
        +	cfg    *Config

      
        
        61
        +	indent string

      
        
        62
        +}

      
        
        63
        +

      
        
        64
        +// Fprint formats using the default config.

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

      
        
        66
        +

      
        
        67
        +// Fprint formats a parsed journal.

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

      
        
        69
        +	if c == nil {

      
        
        70
        +		c = defaultConfig

      
        
        71
        +	}

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

      
        
        73
        +

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

      
        
        75
        +		p.formatEntry(e)

      
        
        76
        +	}

      
        
        77
        +

      
        
        78
        +	// allow exactly one trailing newline

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

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

      
        
        81
        +	return err

      
        
        82
        +}

      
        
        83
        +

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

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

      
        
        86
        +	case *ast.Transaction:

      
        
        87
        +		p.writeTransaction(e)

      
        
        88
        +		return

      
        
        89
        +	case *ast.PeriodicTransaction:

      
        
        90
        +		p.writePeriodicTransaction(e)

      
        
        91
        +		return

      
        
        92
        +	case *ast.AutomatedTransaction:

      
        
        93
        +		p.writeAutomatedTransaction(e)

      
        
        94
        +		return

      
        
        95
        +	case *ast.BlankLine:

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

      
        
        97
        +		return

      
        
        98
        +

      
        
        99
        +	case *ast.Comment:

      
        
        100
        +		p.writeComment(e)

      
        
        101
        +	case *ast.AccountDirective:

      
        
        102
        +		p.writeAccountDirective(e)

      
        
        103
        +	case *ast.CommodityDirective:

      
        
        104
        +		p.writeCommodityDirective(e)

      
        
        105
        +	case *ast.IncludeDirective:

      
        
        106
        +		p.writeIncludeDirective(e)

      
        
        107
        +	case *ast.AliasDirective:

      
        
        108
        +		p.writeAliasDirective(e)

      
        
        109
        +	case *ast.PayeeDirective:

      
        
        110
        +		p.writePayeeDirective(e)

      
        
        111
        +	case *ast.TagDirective:

      
        
        112
        +		p.writeTagDirective(e)

      
        
        113
        +	case *ast.YearDirective:

      
        
        114
        +		p.writeYearDirective(e)

      
        
        115
        +	case *ast.DecimalMarkDirective:

      
        
        116
        +		p.writeDecimalMarkDirective(e)

      
        
        117
        +	case *ast.MarketPriceDirective:

      
        
        118
        +		p.writeMarketPriceDirective(e)

      
        
        119
        +	case *ast.ConversionDirective:

      
        
        120
        +		p.writeConversionDirective(e)

      
        
        121
        +	case *ast.DefaultCommodityDirective:

      
        
        122
        +		p.writeDefaultCommodityDirective(e)

      
        
        123
        +	case *ast.ApplyDirective:

      
        
        124
        +		p.writeApplyDirective(e)

      
        
        125
        +	case *ast.EndDirective:

      
        
        126
        +		p.writeEndDirective(e)

      
        
        127
        +	case *ast.CommentBlockDirective:

      
        
        128
        +		p.writeCommentBlockDirective(e)

      
        
        129
        +	case *ast.IgnoredDirective:

      
        
        130
        +		return // TODO:

      
        
        131
        +	default:

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

      
        
        133
        +	}

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

      
        
        135
        +}

      
        
        136
        +

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

      
        
        138
        +	for range n {

      
        
        139
        +		p.buf.WriteByte(' ')

      
        
        140
        +	}

      
        
        141
        +}

      
        
        142
        +

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

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

      
        
        145
        +		p.buf.WriteByte(c.Marker) // TODO: support other markers

      
        
        146
        +		p.buf.WriteByte(' ')

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

      
        
        148
        +	}

      
        
        149
        +}

      
        
        150
        +

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

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

      
        
        153
        +		p.buf.WriteByte(' ')

      
        
        154
        +		p.buf.WriteByte(c.Marker) // TODO: support other markers

      
        
        155
        +		p.buf.WriteByte(' ')

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

      
        
        157
        +	}

      
        
        158
        +}

      
        
        159
        +

      
        
        160
        +func spaces(n int) string {

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

      
        
        162
        +	for i := range b {

      
        
        163
        +		b[i] = ' '

      
        
        164
        +	}

      
        
        165
        +	return string(b)

      
        
        166
        +}

      
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
        +// TODO: test with custom config options

      
        
        12
        +

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

      
        
        14
        +	tests := []string{"accounts", "basic", "entries", "amounts", "directives", "sample"}

      
        
        15
        +	for _, tname := range tests {

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

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

      
        
        18
        +

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

      
        
        20
        +			if err != nil {

      
        
        21
        +				t.Fatal(err)

      
        
        22
        +			}

      
        
        23
        +			var b strings.Builder

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

      
        
        25
        +				t.Fatal(err)

      
        
        26
        +			}

      
        
        27
        +

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

      
        
        29
        +		})

      
        
        30
        +	}

      
        
        31
        +}

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

      
        
        2
        +account assets

      
        
        3
        +account assets:bank

      
        
        4
        +account assets:investment:inzhur

      
        
        5
        +account assets:oschad

      
        
        6
        +account assets:receivable:maks

      
        
        7
        +

      
        
        8
        +account equity

      
        
        9
        +account equity:misc

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

      
        
        2
        +account assets

      
        
        3
        +account assets:bank

      
        
        4
        +account assets:investment:inzhur

      
        
        5
        +account assets:oschad

      
        
        6
        +account assets:receivable:maks

      
        
        7
        +

      
        
        8
        +account equity

      
        
        9
        +account equity:misc

      
A journal/printer/testdata/amounts.golden
···
        
        1
        +2026-01-15 * Cost test

      
        
        2
        +  Expenses:Food  100.00$ @ 1.50$

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

      
        
        4
        +  ; continuation comment

      
        
        5
        +

      
        
        6
        +2026-02-01 * Balance assertion test

      
        
        7
        +  Expenses:Food  50.00$ = 500.00$

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

      
        
        9
        +  Assets:Total   === 0.00$

      
        
        10
        +

      
        
        11
        +2026-03-01 Multi-posting

      
        
        12
        +  Expenses:Food  30.00$

      
        
        13
        +  Expenses:Dining  20.00$

      
        
        14
        +  Assets:Cash  -50.00$

      
A journal/printer/testdata/amounts.input
···
        
        1
        +2026/01/15 * Cost test

      
        
        2
        +    Expenses:Food  $100.00 @ $1.50

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

      
        
        4
        +    ; continuation comment

      
        
        5
        +

      
        
        6
        +2026/02/01 * Balance assertion test

      
        
        7
        +    Expenses:Food  $50.00 = $500.00

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

      
        
        9
        +    Assets:Total        === $0.00

      
        
        10
        +

      
        
        11
        +2026/03/01 Multi-posting

      
        
        12
        +    Expenses:Food    $30.00

      
        
        13
        +    Expenses:Dining  $20.00

      
        
        14
        +    Assets:Cash     $-50.00

      
A journal/printer/testdata/basic.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/basic.input
···
        
        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/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/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
        +P 2026-01-15 12:00:00 GOOG 150.00$

      
        
        10
        +

      
        
        11
        +; Default commodity

      
        
        12
        +D 1,000.00$

      
        
        13
        +

      
        
        14
        +; Apply/end

      
        
        15
        +apply account Expenses

      
        
        16
        +end

      
        
        17
        +

      
        
        18
        +; Comment block

      
        
        19
        +comment

      
        
        20
        +Some notes about the file

      
        
        21
        +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 ,

      
        
        10
        +

      
        
        11
        +; Market price

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

      
        
        13
        +

      
        
        14
        +; Default commodity

      
        
        15
        +D $1,000.00

      
        
        16
        +

      
        
        17
        +; Apply/end

      
        
        18
        +apply account Expenses

      
        
        19
        +end

      
        
        20
        +

      
        
        21
        +; Comment block

      
        
        22
        +comment

      
        
        23
        +Some notes about the file

      
        
        24
        +end comment

      
A journal/printer/testdata/entries.golden
···
        
        1
        +~ monthly

      
        
        2
        +  Expenses:Food  100.00$

      
        
        3
        +  Assets:Cash  -100.00$

      
        
        4
        +

      
        
        5
        += account:Expenses

      
        
        6
        +  Expenses:Food  100.00$

      
        
        7
        +  Assets:Cash  -100.00$

      
        
        8
        +

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

      
        
        10
        +  Expenses:Food  100.00$

      
        
        11
        +  Assets:Cash  -100.00$

      
        
        12
        +

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

      
        
        14
        +  (Expenses:Food)  100.00$

      
        
        15
        +  [Assets:Cash]  -100.00$

      
A journal/printer/testdata/entries.input
···
        
        1
        +~ monthly

      
        
        2
        +    Expenses:Food    $100.00

      
        
        3
        +    Assets:Cash     $-100.00

      
        
        4
        +

      
        
        5
        += account:Expenses

      
        
        6
        +    Expenses:Food    $100.00

      
        
        7
        +    Assets:Cash     $-100.00

      
        
        8
        +

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

      
        
        10
        +    Expenses:Food    $100.00

      
        
        11
        +    Assets:Cash     $-100.00

      
        
        12
        +

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

      
        
        14
        +    (Expenses:Food)    $100.00

      
        
        15
        +    [Assets:Cash]     $-100.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 expenses

      
        
        9
        +account expenses:education

      
        
        10
        +; }}}

      
        
        11
        +; 01 Jan {{{

      
        
        12
        +2026-01-01 opening balances

      
        
        13
        +  assets:bank  123.00 USD

      
        
        14
        +  assets:cash  1234.12 USD

      
        
        15
        +  equity:open

      
        
        16
        +

      
        
        17
        +2026-01-04 selary

      
        
        18
        +  income:salary  543.22$

      
        
        19
        +  assets:bank

      
        
        20
        +; }}}

      
        
        21
        +; 02 Feb {{{

      
        
        22
        +2026-02-01 internet

      
        
        23
        +  expenses:utilities  350.00 UAH

      
        
        24
        +  assets:bank

      
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 expenses

      
        
        9
        +account expenses:education

      
        
        10
        +; }}}

      
        
        11
        +; 01 Jan {{{

      
        
        12
        +2026-01-01 opening balances

      
        
        13
        +  assets:bank  123.00 USD

      
        
        14
        +  assets:cash  1234.12 USD

      
        
        15
        +  equity:open

      
        
        16
        +

      
        
        17
        +2026-01-04 selary

      
        
        18
        +  income:salary  $543.22

      
        
        19
        +  assets:bank

      
        
        20
        +; }}}

      
        
        21
        +; 02 Feb {{{

      
        
        22
        +2026-02-01 internet

      
        
        23
        +  expenses:utilities  350 UAH

      
        
        24
        +  assets:bank

      
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
        +func (p *printer) writeDate(d ast.Date) {

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

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

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

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

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

      
        
        133
        +}

      
        
        134
        +

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

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

      
        
        137
        +	var b [10]byte

      
        
        138
        +	pos := len(b)

      
        
        139
        +	for range digits {

      
        
        140
        +		pos--

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

      
        
        142
        +		n /= 10

      
        
        143
        +	}

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

      
        
        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: