25 files changed,
1138 insertions(+),
3 deletions(-)
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
jump to
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/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/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 +}