33 files changed,
1484 insertions(+),
27 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-06-07 16:49:36 +0300
Authored at:
2026-06-07 16:48:45 +0300
Change ID:
mkuqlovqrzypwzywtouxulklvusxonul
Parent:
e5cc255
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 + check := fs.Bool("c", false, "Exit code 0 if already formatted, 1 otherwise") 17 + diff := fs.Bool("d", false, "Display diffs instead of rewriting files") 18 + list := fs.Bool("l", false, "List files whose formatting differs") 19 + write := fs.Bool("w", false, "Write result back to file instead of stdout") 20 + fs.Usage = func() { 21 + fmt.Fprintf(os.Stderr, "Usage: clerk format [flags] [path ...]\n") 22 + fs.PrintDefaults() 23 + } 24 + fs.Parse(args) 25 + 26 + paths := fs.Args() 27 + 28 + // Read from stdin if no paths given 29 + if len(paths) == 0 { 30 + src, err := io.ReadAll(os.Stdin) 31 + if err != nil { 32 + fmt.Fprintf(os.Stderr, "error reading stdin: %v\n", err) 33 + os.Exit(1) 34 + } 35 + 36 + pf, err := journal.NewLoader().LoadBytes("stdin", src) 37 + if err != nil { 38 + fmt.Fprintf(os.Stderr, "parse error: %v\n", err) 39 + os.Exit(1) 40 + } 41 + if len(pf.Errors) > 0 { 42 + fmt.Fprintf(os.Stderr, "parse error: %v\n", pf.Errors[0].Message) 43 + os.Exit(1) 44 + } 45 + 46 + var buf bytes.Buffer 47 + if err := printer.Fprint(&buf, pf.Ast); err != nil { 48 + fmt.Fprintf(os.Stderr, "format error: %v\n", err) 49 + os.Exit(1) 50 + } 51 + 52 + formatted := buf.Bytes() 53 + 54 + switch { 55 + case *check: 56 + if bytes.Equal(src, formatted) { 57 + os.Exit(0) 58 + } 59 + os.Exit(1) 60 + case *diff: 61 + diffLines("stdin", src, formatted) 62 + default: 63 + os.Stdout.Write(formatted) 64 + } 65 + return 66 + } 67 + 68 + // Process each file 69 + exitCode := 0 70 + for _, path := range paths { 71 + info, err := os.Stat(path) 72 + if err != nil { 73 + fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err) 74 + exitCode = 1 75 + continue 76 + } 77 + if info.IsDir() { 78 + fmt.Fprintf(os.Stderr, "error: %s: is a directory\n", path) 79 + exitCode = 1 80 + continue 81 + } 82 + 83 + src, err := os.ReadFile(path) 84 + if err != nil { 85 + fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err) 86 + exitCode = 1 87 + continue 88 + } 89 + 90 + pf, err := journal.NewLoader().LoadBytes(path, src) 91 + if err != nil { 92 + fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err) 93 + exitCode = 1 94 + continue 95 + } 96 + if len(pf.Errors) > 0 || len(pf.FileErrors) > 0 { 97 + fmt.Fprintf(os.Stderr, "error: %s: has errors, refusing to format\n", path) 98 + exitCode = 1 99 + continue 100 + } 101 + 102 + var buf bytes.Buffer 103 + if err := printer.Fprint(&buf, pf.Ast); err != nil { 104 + fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err) 105 + exitCode = 1 106 + continue 107 + } 108 + 109 + formatted := buf.Bytes() 110 + changed := !bytes.Equal(src, formatted) 111 + 112 + switch { 113 + case *check: 114 + if changed { 115 + fmt.Fprintf(os.Stderr, "%s: not formatted\n", path) 116 + exitCode = 1 117 + } 118 + case *list: 119 + if changed { 120 + fmt.Println(path) 121 + } 122 + case *diff: 123 + if changed { 124 + diffLines(path, src, formatted) 125 + } 126 + case *write: 127 + if changed { 128 + if err := os.WriteFile(path, formatted, 0o644); err != nil { 129 + fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err) 130 + exitCode = 1 131 + } 132 + } 133 + default: 134 + if _, err := os.Stdout.Write(formatted); err != nil { 135 + fmt.Fprintf(os.Stderr, "error writing stdout: %v\n", err) 136 + exitCode = 1 137 + } 138 + } 139 + } 140 + os.Exit(exitCode) 141 +} 142 + 143 +func diffLines(path string, src, formatted []byte) { 144 + fmt.Printf("--- %s\n+++ %s\n", path, path) 145 + srcLines := bytes.Split(src, []byte("\n")) 146 + fmtLines := bytes.Split(formatted, []byte("\n")) 147 + lines := max(len(fmtLines), len(srcLines)) 148 + for i := range lines { 149 + var sLine, fLine []byte 150 + if i < len(srcLines) { 151 + sLine = srcLines[i] 152 + } 153 + if i < len(fmtLines) { 154 + fLine = fmtLines[i] 155 + } 156 + if !bytes.Equal(sLine, fLine) { 157 + if len(sLine) > 0 { 158 + fmt.Printf("-%s\n", sLine) 159 + } else { 160 + fmt.Println("-") 161 + } 162 + if len(fLine) > 0 { 163 + fmt.Printf("+%s\n", fLine) 164 + } else { 165 + fmt.Println("+") 166 + } 167 + } 168 + } 169 +}
M
internal/decimal/decimal.go
··· 101 101 return sign + digits[:split] + "." + digits[split:] 102 102 } 103 103 104 +// StringFixed returns a string representation with exactly places digits 105 +// after the decimal point. Pads with zeros or truncates as needed. 106 +// decSep and thousandsSep control formatting; zero values mean no custom separator. 107 +func (d Decimal) StringFixed(places int, decSep, thousandsSep byte) string { 108 + var sb strings.Builder 109 + sb.Grow(32) 110 + d.WriteFixed(&sb, places, decSep, thousandsSep) 111 + return sb.String() 112 +} 113 + 114 +// WriteFixed writes a string representation with exactly places digits 115 +// after the decimal point directly into sb. Pads with zeros or truncates 116 +// as needed. decSep and thousandsSep control formatting; zero values mean 117 +// no custom separator. 118 +func (d Decimal) WriteFixed(sb *strings.Builder, places int, decSep, thousandsSep byte) { 119 + if d.IsZero() { 120 + sb.WriteByte('0') 121 + if places > 0 { 122 + if decSep != 0 { 123 + sb.WriteByte(decSep) 124 + } else { 125 + sb.WriteByte('.') 126 + } 127 + for range places { 128 + sb.WriteByte('0') 129 + } 130 + } 131 + return 132 + } 133 + 134 + var digitBuf [128]byte 135 + digits := d.coeff.Append(digitBuf[:0], 10) 136 + 137 + sign := false 138 + if len(digits) > 0 && digits[0] == '-' { 139 + sign = true 140 + digits = digits[1:] 141 + } 142 + 143 + intLen := len(digits) - d.scale 144 + 145 + if sign { 146 + sb.WriteByte('-') 147 + } 148 + 149 + // Integer part 150 + if intLen <= 0 { 151 + sb.WriteByte('0') 152 + } else { 153 + for i := range intLen { 154 + if thousandsSep != 0 && i > 0 && (intLen-i)%3 == 0 { 155 + sb.WriteByte(thousandsSep) 156 + } 157 + sb.WriteByte(digits[i]) 158 + } 159 + } 160 + 161 + // Fractional part 162 + if places > 0 { 163 + if decSep != 0 { 164 + sb.WriteByte(decSep) 165 + } else { 166 + sb.WriteByte('.') 167 + } 168 + 169 + written := 0 170 + if intLen > 0 { 171 + n := min(d.scale, places) 172 + for i := range n { 173 + sb.WriteByte(digits[intLen+i]) 174 + written++ 175 + } 176 + } else { 177 + leadingZeros := -intLen 178 + for ; written < leadingZeros && written < places; written++ { 179 + sb.WriteByte('0') 180 + } 181 + for i := 0; i < len(digits) && written < places; i++ { 182 + sb.WriteByte(digits[i]) 183 + written++ 184 + } 185 + } 186 + for ; written < places; written++ { 187 + sb.WriteByte('0') 188 + } 189 + } 190 +} 191 + 104 192 func (d Decimal) Neg() Decimal { 105 193 if d.coeff == nil || d.coeff.Sign() == 0 { 106 194 return Decimal{}
M
internal/decimal/decimal_test.go
··· 28 28 29 29 func TestNewFromStringInvalid(t *testing.T) { 30 30 tests := []string{ 31 - "", 32 - ".", 33 - "+", 34 - "-", 35 - "1_000.00", 36 - "1,000.00", 31 + "", ".", "+", "-", 32 + "1_000.00", "1,000.00", 37 33 "1..0", 38 - "a1", 39 - "1a", 34 + "a1", "1a", 40 35 } 41 36 for _, in := range tests { 42 37 if _, err := FromString(in); err == nil { 43 38 t.Fatalf("NewFromString(%q) expected error", in) 39 + } 40 + } 41 +} 42 + 43 +func TestStringFixed(t *testing.T) { 44 + tests := []struct { 45 + in string 46 + places int 47 + decSep byte 48 + thousandsSep byte 49 + want string 50 + }{ 51 + {"0", 0, 0, 0, "0"}, 52 + {"0", 1, 0, 0, "0.0"}, 53 + {"0", 2, 0, 0, "0.00"}, 54 + {"0", 3, 0, 0, "0.000"}, 55 + {"-0", 2, 0, 0, "0.00"}, 56 + {"10", 2, 0, 0, "10.00"}, 57 + {"10", 0, 0, 0, "10"}, 58 + {"1.2", 2, 0, 0, "1.20"}, 59 + {"1.2", 1, 0, 0, "1.2"}, 60 + {"1.2", 3, 0, 0, "1.200"}, 61 + {"1.234", 2, 0, 0, "1.23"}, 62 + {"1.235", 2, 0, 0, "1.23"}, 63 + {"-1.2", 2, 0, 0, "-1.20"}, 64 + {"-1.234", 2, 0, 0, "-1.23"}, 65 + {"0.001", 2, 0, 0, "0.00"}, 66 + {"0.001", 4, 0, 0, "0.0010"}, 67 + {"123.456789", 3, 0, 0, "123.456"}, 68 + {"-123.456789", 3, 0, 0, "-123.456"}, 69 + {"1.23", 2, ',', 0, "1,23"}, 70 + {"0", 2, ',', 0, "0,00"}, 71 + {"-1.5", 2, ',', 0, "-1,50"}, 72 + {"1234.56", 2, 0, ',', "1,234.56"}, 73 + {"1234567.89", 2, 0, ',', "1,234,567.89"}, 74 + {"123.45", 2, 0, ',', "123.45"}, 75 + {"-1234.56", 2, 0, ',', "-1,234.56"}, 76 + {"1234567.89", 2, ',', '.', "1.234.567,89"}, 77 + {"-1234567.89", 2, ',', '.', "-1.234.567,89"}, 78 + {"0", 2, ',', '.', "0,00"}, 79 + } 80 + for _, tt := range tests { 81 + d, err := FromString(tt.in) 82 + if err != nil { 83 + t.Fatalf("FromString(%q) unexpected error: %v", tt.in, err) 84 + } 85 + if got := d.StringFixed(tt.places, tt.decSep, tt.thousandsSep); got != tt.want { 86 + t.Fatalf("FromString(%q).StringFixed(%d, %q, %q) = %q, want %q", 87 + tt.in, tt.places, tt.decSep, tt.thousandsSep, got, tt.want) 44 88 } 45 89 } 46 90 }
M
journal/ast/entries.go
··· 11 11 12 12 type Transaction struct { 13 13 Date Date 14 - SecondDate *Date // optional =2026-05-18 date 15 - Status *Status // optional */! status 16 - Code *string // optional (123) code 17 - Payee *Payee // optional payee 18 - Note *string // part after | 19 - Comment *Comment // inline ; on header line 20 - HeaderComments []Comment // indented ; lines before first posting 14 + SecondDate *Date // optional =2026-05-18 date 15 + Status *Status // optional */! status 16 + Code *string // optional (123) code 17 + Payee *Payee // optional payee 18 + Note *string // part after | 19 + Comment *Comment // inline ; on header line 20 + HeaderComments []*Comment // indented ; lines before first posting 21 21 Postings []*Posting 22 22 Span token.Span 23 23 }
M
journal/lexer/lexer.go
··· 51 51 col int // current column (1-based) 52 52 line int // current line (1-based) 53 53 54 - postingExpectAccount bool 54 + transactionPastStatus bool 55 + postingExpectAccount bool 55 56 } 56 57 57 58 func New(file string, input []byte) *Lexer { ··· 137 138 } 138 139 tok := l.lexDate() 139 140 l.mode = ModeTransaction 141 + l.transactionPastStatus = false 140 142 return tok 141 143 default: 142 144 s := l.save() ··· 183 185 return l.lexSingle(token.SEMICOLON) 184 186 case ' ', '\t': 185 187 return l.lexWhitespace() 186 - case '*': // * after date = status 187 - return l.lexSingle(token.STAR) 188 + case '*': 189 + if !l.transactionPastStatus { 190 + l.transactionPastStatus = true 191 + return l.lexSingle(token.STAR) 192 + } 193 + return l.lexText() 188 194 case '!': 189 - return l.lexSingle(token.BANG) 195 + if !l.transactionPastStatus { 196 + l.transactionPastStatus = true 197 + return l.lexSingle(token.BANG) 198 + } 199 + return l.lexText() 190 200 case '|': 191 201 return l.lexSingle(token.PIPE) 192 202 case '+': ··· 216 226 l.col = 0 217 227 l.advance() 218 228 return l.lexNewline() 219 - case ';', '%', '#': 229 + case ';': 220 230 l.mode = ModeComment 221 - return l.lexSingle(token.SEMICOLON) // todo: ?? 231 + return l.lexSingle(token.SEMICOLON) 222 232 case ' ', '\t': 223 233 return l.lexWhitespace() 224 234 default: ··· 239 249 return l.lexNewline() 240 250 case ' ', '\t': 241 251 return l.lexWhitespace() 242 - case ';', '%', '#': 252 + case ';': 243 253 l.mode = ModeComment 244 - return l.lexSingle(token.SEMICOLON) // todo: ?? 254 + return l.lexSingle(token.SEMICOLON) 245 255 default: 246 256 return l.lexText() 247 257 }
M
journal/lexer/lexer_test.go
··· 46 46 expenses:food 40.00 гривні 47 47 assets:cash 48 48 `}, 49 +{"bangs and stars in transaction description", `2026-06-07 * payee !one | something *important* 50 + expenses:food 40.00 51 + assets:cash 52 +`}, 49 53 {"date with secondary", `2024/01/01=2024/01/02 groceries`}, 50 54 {"better date", `2024-01-02`}, 51 55 {"comment line", `; this is a comment`},
A
journal/lexer/testdata/golden/Lexer__bangs_and_stars_in_transaction_description.golden
··· 1 +DATE "2026-06-07" 1:1-1:11 2 +WHITESPACE " " 1:11-1:12 3 +STAR "*" 1:12-1:13 4 +WHITESPACE " " 1:13-1:14 5 +TEXT "payee" 1:14-1:19 6 +WHITESPACE " " 1:19-1:20 7 +TEXT "!one" 1:20-1:24 8 +WHITESPACE " " 1:24-1:25 9 +PIPE "|" 1:25-1:26 10 +WHITESPACE " " 1:26-1:27 11 +TEXT "something" 1:27-1:36 12 +WHITESPACE " " 1:36-1:37 13 +TEXT "*important*" 1:37-2:0 14 +NEWLINE "\n" 2:0-2:1 15 +INDENT " " 2:1-2:5 16 +TEXT "expenses:food" 2:5-2:18 17 +WHITESPACE " " 2:18-2:20 18 +DECIMAL "40.00" 2:20-3:0 19 +NEWLINE "\n" 3:0-3:1 20 +INDENT " " 3:1-3:5 21 +TEXT "assets:cash" 3:5-4:0 22 +NEWLINE "\n" 4:0-4:1 23 +EOF "" 4:1-4:1
M
journal/parser/parser.go
··· 167 167 for p.got(token.INDENT) && p.willGet(token.SEMICOLON) { 168 168 p.advance() // consume indent 169 169 c := p.parseComment() 170 - tx.HeaderComments = append(tx.HeaderComments, *c) 170 + tx.HeaderComments = append(tx.HeaderComments, c) 171 171 } 172 172 173 173 // postings ··· 1099 1099 1100 1100 func (p *Parser) parseOptInlineComment() *ast.Comment { 1101 1101 p.skipWhitespace() // todo: 1102 - if p.cur.Type != token.SEMICOLON && p.cur.Type != token.HASH { 1102 + if p.cur.Type != token.SEMICOLON { 1103 1103 return nil 1104 1104 } 1105 1105
A
journal/printer/amount.go
··· 1 +package printer 2 + 3 +import ( 4 + "olexsmir.xyz/clerk/internal/decimal" 5 + "olexsmir.xyz/clerk/journal/ast" 6 +) 7 + 8 +func (p *printer) writeAmount(a *ast.Amount, pos CommodityPos) { 9 + if a == nil { 10 + return 11 + } 12 + 13 + if a.IsExpr { 14 + p.buf.WriteString(a.Expr) 15 + return 16 + } 17 + 18 + comm := a.Commodity 19 + if comm == "" { 20 + p.writeDecimal(a.Quantity, a.QuantityFmt, 2) 21 + return 22 + } 23 + 24 + switch pos { 25 + case CommodityBefore: 26 + p.buf.WriteString(comm) 27 + if a.HasSpace { 28 + p.buf.WriteByte(' ') 29 + } 30 + p.writeDecimal(a.Quantity, a.QuantityFmt, 2) 31 + case CommodityAfter: 32 + p.writeDecimal(a.Quantity, a.QuantityFmt, 2) 33 + if a.HasSpace { 34 + p.buf.WriteByte(' ') 35 + } 36 + p.buf.WriteString(comm) 37 + default: 38 + panic("impossible CommodityPos value") 39 + } 40 +} 41 + 42 +func (p *printer) writeCost(c *ast.Cost, pos CommodityPos) { 43 + if c == nil { 44 + return 45 + } 46 + if c.IsTotal { 47 + p.buf.WriteString(" @@ ") 48 + } else { 49 + p.buf.WriteString(" @ ") 50 + } 51 + p.writeAmount(&c.Amount, pos) 52 +} 53 + 54 +func (p *printer) writeBalanceAssertion(ba *ast.BalanceAssertion, pos CommodityPos) { 55 + if ba == nil { 56 + return 57 + } 58 + switch { 59 + case ba.IsInclusive: 60 + p.buf.WriteString("=== ") 61 + case ba.IsStrict: 62 + p.buf.WriteString("== ") 63 + default: 64 + p.buf.WriteString("= ") 65 + } 66 + p.writeAmount(&ba.Amount, pos) 67 +} 68 + 69 +func (p *printer) writeDecimal(d decimal.Decimal, fmt ast.QuantityFormat, forcePrec int) { 70 + d.WriteFixed(&p.buf, forcePrec, fmt.Decimal, fmt.Thousands) 71 +}
A
journal/printer/directives.go
··· 1 +package printer 2 + 3 +import ( 4 + "strconv" 5 + "strings" 6 + 7 + "olexsmir.xyz/clerk/journal/ast" 8 +) 9 + 10 +func (p *printer) writeIncludeDirective(i *ast.IncludeDirective) { 11 + p.buf.WriteString("include ") 12 + p.buf.WriteString(quoteString(i.Path)) 13 + p.writeInlineComment(i.Comment) 14 +} 15 + 16 +func (p *printer) writeAccountDirective(a *ast.AccountDirective) { 17 + p.buf.WriteString("account ") 18 + p.buf.WriteString(a.Account.Name) 19 + p.writeInlineComment(a.Comment) 20 +} 21 + 22 +func (p *printer) writeCommodityDirective(c *ast.CommodityDirective) { 23 + p.buf.WriteString("commodity ") 24 + p.buf.WriteString(c.Commodity) 25 + p.writeInlineComment(c.Comment) 26 +} 27 + 28 +func (p *printer) writeAliasDirective(a *ast.AliasDirective) { 29 + p.buf.WriteString("alias ") 30 + p.buf.WriteString(a.From) 31 + p.buf.WriteString(" = ") 32 + p.buf.WriteString(a.To) 33 + p.writeInlineComment(a.Comment) 34 +} 35 + 36 +func (p *printer) writePayeeDirective(pd *ast.PayeeDirective) { 37 + p.buf.WriteString("payee ") 38 + p.buf.WriteString(quoteString(pd.Name)) 39 + p.writeInlineComment(pd.Comment) 40 +} 41 + 42 +func (p *printer) writeTagDirective(t *ast.TagDirective) { 43 + p.buf.WriteString("tag ") 44 + p.buf.WriteString(quoteString(t.Name)) 45 + p.writeInlineComment(t.Comment) 46 +} 47 + 48 +func (p *printer) writeYearDirective(y *ast.YearDirective) { 49 + p.buf.WriteString("year ") 50 + p.writeInt(y.Year, 4) 51 + p.writeInlineComment(y.Comment) 52 +} 53 + 54 +func (p *printer) writeDecimalMarkDirective(d *ast.DecimalMarkDirective) { 55 + p.buf.WriteString("decimal-mark ") 56 + p.buf.WriteByte(d.Mark) 57 + p.writeInlineComment(d.Comment) 58 +} 59 + 60 +func (p *printer) writeDefaultCommodityDirective(d *ast.DefaultCommodityDirective) { 61 + p.buf.WriteString("D ") 62 + p.writeAmount(&d.Amount, p.cfg.CommodityPos) 63 + p.writeInlineComment(d.Comment) 64 +} 65 + 66 +func (p *printer) writeConversionDirective(c *ast.ConversionDirective) { 67 + p.buf.WriteString("C ") 68 + p.writeAmount(&c.From, p.cfg.CommodityPos) 69 + p.buf.WriteString(" = ") 70 + p.writeAmount(&c.To, p.cfg.CommodityPos) 71 + p.writeInlineComment(c.Comment) 72 +} 73 + 74 +func (p *printer) writeMarketPriceDirective(m *ast.MarketPriceDirective) { 75 + p.buf.WriteString("P ") 76 + p.writeDate(m.DateTime.Date) 77 + p.writeTime(m.DateTime.Time) 78 + p.buf.WriteByte(' ') 79 + p.buf.WriteString(m.Commodity) 80 + p.buf.WriteByte(' ') 81 + p.writeAmount(&m.Amount, p.cfg.CommodityPos) 82 + p.writeInlineComment(m.Comment) 83 +} 84 + 85 +func (p *printer) writeCommentBlockDirective(cb *ast.CommentBlockDirective) { 86 + p.buf.WriteString("comment") 87 + if cb.Header != "" { 88 + p.buf.WriteByte(' ') 89 + p.buf.WriteString(cb.Header) 90 + } 91 + p.buf.WriteByte('\n') 92 + if cb.Content != "" { 93 + p.buf.WriteString(cb.Content) 94 + if !strings.HasSuffix(cb.Content, "\n") { 95 + p.buf.WriteByte('\n') 96 + } 97 + } 98 + p.buf.WriteString("end") 99 + if cb.Header != "" { 100 + p.buf.WriteByte(' ') 101 + p.buf.WriteString(cb.Header) 102 + } 103 +} 104 + 105 +func (p *printer) writeApplyDirective(a *ast.ApplyDirective) { 106 + p.buf.WriteString("apply ") 107 + p.buf.WriteString(a.Expr) 108 + p.writeInlineComment(a.Comment) 109 +} 110 + 111 +func (p *printer) writeEndDirective(e *ast.EndDirective) { 112 + p.buf.WriteString("end") 113 + if e.Expr != "" { 114 + p.buf.WriteByte(' ') 115 + p.buf.WriteString(e.Expr) 116 + } 117 + p.writeInlineComment(e.Comment) 118 +} 119 + 120 +func quoteString(s string) string { 121 + needsQuote := false 122 + for _, c := range s { 123 + if c == ' ' || c == '\t' || c == '"' || c == ';' || c == '#' { 124 + needsQuote = true 125 + break 126 + } 127 + } 128 + if !needsQuote { 129 + return s 130 + } 131 + return strconv.Quote(s) 132 +}
M
journal/printer/printer.go
··· 1 1 package printer 2 + 3 +import ( 4 + "fmt" 5 + "io" 6 + "strings" 7 + 8 + "olexsmir.xyz/clerk/journal/ast" 9 +) 10 + 11 +// AlignStyle controls how postings are aligned 12 +type AlignStyle int 13 + 14 +const ( 15 + AlignTwoSpaces AlignStyle = iota // " Account $10.00" 16 + AlignRight // amounts at fixed col: " Account $10.00" 17 + AlignTab // elastic tabstops 18 +) 19 + 20 +// CommodityPos controls where the commodity marker is placed 21 +type CommodityPos int 22 + 23 +const ( 24 + CommodityAfter CommodityPos = iota // "10.00 EUR" 25 + CommodityBefore // "$10.00" 26 +) 27 + 28 +type Config struct { 29 + TabIndent bool // true = tabs, false = spaces 30 + IndentWidth int // spaces per indent level (default: 2) 31 + AlignStyle AlignStyle // (default AlignTwoSpaces) 32 + AlignColumn int // fixed column for AlignRight 33 + CommodityPos CommodityPos // where to place commodity 34 +} 35 + 36 +var defaultConfig = &Config{ 37 + TabIndent: false, 38 + IndentWidth: 2, 39 + AlignStyle: AlignTwoSpaces, 40 + AlignColumn: 70, 41 + CommodityPos: CommodityAfter, 42 +} 43 + 44 +func (c *Config) indent() string { 45 + if c.TabIndent { 46 + return "\t" 47 + } 48 + n := 2 49 + if c.IndentWidth > 0 { 50 + n = c.IndentWidth 51 + } 52 + return spaces(n) 53 +} 54 + 55 +// printer holds formatting state for a single Fprint call. 56 +type printer struct { 57 + buf strings.Builder 58 + cfg *Config 59 + indent string 60 +} 61 + 62 +// Fprint formats using the default config. 63 +func Fprint(w io.Writer, j *ast.Journal) error { return defaultConfig.Fprint(w, j) } 64 + 65 +// Fprint formats a parsed journal. 66 +func (c *Config) Fprint(w io.Writer, j *ast.Journal) error { 67 + if c == nil { 68 + c = defaultConfig 69 + } 70 + p := printer{cfg: c, indent: c.indent()} 71 + 72 + for _, e := range j.Entries { 73 + p.formatEntry(e) 74 + } 75 + 76 + // allow exactly one trailing newline 77 + out := strings.TrimRight(p.buf.String(), "\n") + "\n" 78 + _, err := io.WriteString(w, out) 79 + return err 80 +} 81 + 82 +// FprintEntry formats a single ast entry using the default config. 83 +func FprintEntry(w io.Writer, e ast.Entry) error { 84 + return defaultConfig.FprintEntry(w, e) 85 +} 86 + 87 +// FprintEntry formats a single journal entry. 88 +func (c *Config) FprintEntry(w io.Writer, e ast.Entry) error { 89 + if c == nil { 90 + c = defaultConfig 91 + } 92 + p := printer{cfg: c, indent: c.indent()} 93 + p.formatEntry(e) 94 + _, err := io.WriteString(w, p.buf.String()) 95 + return err 96 +} 97 + 98 +func (p *printer) formatEntry(e ast.Entry) { 99 + switch e := e.(type) { 100 + case *ast.Transaction: 101 + p.writeTransaction(e) 102 + return 103 + case *ast.PeriodicTransaction: 104 + p.writePeriodicTransaction(e) 105 + return 106 + case *ast.AutomatedTransaction: 107 + p.writeAutomatedTransaction(e) 108 + return 109 + case *ast.BlankLine: 110 + p.buf.WriteByte('\n') 111 + return 112 + case *ast.IgnoredDirective: 113 + return // TODO: 114 + 115 + case *ast.Comment: 116 + p.writeComment(e) 117 + case *ast.AccountDirective: 118 + p.writeAccountDirective(e) 119 + case *ast.CommodityDirective: 120 + p.writeCommodityDirective(e) 121 + case *ast.IncludeDirective: 122 + p.writeIncludeDirective(e) 123 + case *ast.AliasDirective: 124 + p.writeAliasDirective(e) 125 + case *ast.PayeeDirective: 126 + p.writePayeeDirective(e) 127 + case *ast.TagDirective: 128 + p.writeTagDirective(e) 129 + case *ast.YearDirective: 130 + p.writeYearDirective(e) 131 + case *ast.DecimalMarkDirective: 132 + p.writeDecimalMarkDirective(e) 133 + case *ast.MarketPriceDirective: 134 + p.writeMarketPriceDirective(e) 135 + case *ast.ConversionDirective: 136 + p.writeConversionDirective(e) 137 + case *ast.DefaultCommodityDirective: 138 + p.writeDefaultCommodityDirective(e) 139 + case *ast.ApplyDirective: 140 + p.writeApplyDirective(e) 141 + case *ast.EndDirective: 142 + p.writeEndDirective(e) 143 + case *ast.CommentBlockDirective: 144 + p.writeCommentBlockDirective(e) 145 + default: 146 + fmt.Fprintf(&p.buf, "; unknown entry %T", e) 147 + } 148 + p.buf.WriteByte('\n') 149 +} 150 + 151 +func (p *printer) writeSpaces(n int) { 152 + for range n { 153 + p.buf.WriteByte(' ') 154 + } 155 +} 156 + 157 +func isCommentChar(b byte) bool { 158 + return b == ';' || b == '#' || b == '%' || b == '*' 159 +} 160 + 161 +func (p *printer) writeComment(c *ast.Comment) { 162 + if c != nil && c.Text != "" { 163 + p.buf.WriteByte(c.Marker) 164 + if !isCommentChar(c.Text[0]) { 165 + p.buf.WriteByte(' ') 166 + } 167 + p.buf.WriteString(c.Text) 168 + } 169 +} 170 + 171 +func (p *printer) writeInlineComment(c *ast.Comment) { 172 + if c != nil && c.Text != "" { 173 + p.buf.WriteByte(' ') 174 + p.buf.WriteByte(c.Marker) 175 + if !isCommentChar(c.Text[0]) { 176 + p.buf.WriteByte(' ') 177 + } 178 + p.buf.WriteString(c.Text) 179 + } 180 +} 181 + 182 +func spaces(n int) string { 183 + b := make([]byte, n) 184 + for i := range b { 185 + b[i] = ' ' 186 + } 187 + return string(b) 188 +}
A
journal/printer/printer_test.go
··· 1 +package printer 2 + 3 +import ( 4 + "strings" 5 + "testing" 6 + 7 + "olexsmir.xyz/clerk/internal/testutil/golden" 8 + "olexsmir.xyz/clerk/journal" 9 +) 10 + 11 +func TestRoundTrip(t *testing.T) { 12 + tests := []string{"entries", "directives", "sample"} 13 + for _, tname := range tests { 14 + t.Run(tname, func(t *testing.T) { 15 + inp := golden.Load(t, tname) 16 + 17 + pf, err := journal.NewLoader().LoadBytes(tname+".journal", inp) 18 + if err != nil { 19 + t.Fatal(err) 20 + } 21 + var b strings.Builder 22 + if err := defaultConfig.Fprint(&b, pf.Ast); err != nil { 23 + t.Fatal(err) 24 + } 25 + 26 + golden.AssertInput(t, b.String(), tname) 27 + }) 28 + } 29 +} 30 + 31 +func BenchmarkPrinter(b *testing.B) { 32 + for _, tname := range []string{"entries", "directives", "sample"} { 33 + b.Run(tname, func(b *testing.B) { 34 + b.ReportAllocs() 35 + inp := golden.Load(b, tname) 36 + pf, err := journal.NewLoader().LoadBytes(tname+".journal", inp) 37 + if err != nil { 38 + b.Fatal(err) 39 + } 40 + b.ResetTimer() 41 + for i := 0; i < b.N; i++ { 42 + var buf strings.Builder 43 + if err := defaultConfig.Fprint(&buf, pf.Ast); err != nil { 44 + b.Fatal(err) 45 + } 46 + } 47 + }) 48 + } 49 +} 50 + 51 +func TestRoundTrip_WithConfig(t *testing.T) { 52 + tests := map[string]*Config{ 53 + "align_right": {AlignStyle: AlignRight, AlignColumn: 50}, 54 + "align_tab": {AlignStyle: AlignTab, AlignColumn: 50}, 55 + "commodity_before": {CommodityPos: CommodityBefore}, 56 + "tab_indent": {TabIndent: true}, 57 + "indent_width": {IndentWidth: 4}, 58 + } 59 + for tname, tt := range tests { 60 + t.Run(tname, func(t *testing.T) { 61 + inp := golden.Load(t, tname) 62 + pf, err := journal.NewLoader().LoadBytes(tname+".journal", inp) 63 + if err != nil { 64 + t.Fatal(err) 65 + } 66 + var b strings.Builder 67 + if err := tt.Fprint(&b, pf.Ast); err != nil { 68 + t.Fatal(err) 69 + } 70 + 71 + golden.AssertInput(t, b.String(), tname) 72 + }) 73 + } 74 +} 75 + 76 +func BenchmarkPrinter_Config(b *testing.B) { 77 + configs := map[string]*Config{ 78 + "default": defaultConfig, 79 + "align_right": {AlignStyle: AlignRight, AlignColumn: 50}, 80 + "align_tab": {AlignStyle: AlignTab, AlignColumn: 50}, 81 + "commodity_before": {CommodityPos: CommodityBefore}, 82 + "tab_indent": {TabIndent: true}, 83 + "indent_width": {IndentWidth: 4}, 84 + } 85 + inp := golden.Load(b, "entries") 86 + pf, err := journal.NewLoader().LoadBytes("entries.journal", inp) 87 + if err != nil { 88 + b.Fatal(err) 89 + } 90 + for name, cfg := range configs { 91 + b.Run(name, func(b *testing.B) { 92 + b.ReportAllocs() 93 + b.ResetTimer() 94 + for i := 0; i < b.N; i++ { 95 + var buf strings.Builder 96 + if err := cfg.Fprint(&buf, pf.Ast); err != nil { 97 + b.Fatal(err) 98 + } 99 + } 100 + }) 101 + } 102 +}
A
journal/printer/testdata/align_tab.golden
··· 1 +2026-01-15 * Test restaurant 2 + Expenses:FoodAndDrinks 50.00$ 3 + Assets:Cash -50.00$ 4 + 5 +2026-01-16 Another entry with longer account 6 + Expenses:SuperMarket 120.50$ 7 + Assets:Cash -120.50$ 8 + 9 +2026-01-17 Short accounts 10 + a 10.00$ 11 + b -10.00$
A
journal/printer/testdata/align_tab.input
··· 1 +2026-01-15 * Test restaurant 2 + Expenses:FoodAndDrinks 50.00$ 3 + Assets:Cash -50.00$ 4 + 5 +2026-01-16 Another entry with longer account 6 + Expenses:SuperMarket 120.50$ 7 + Assets:Cash -120.50$ 8 + 9 +2026-01-17 Short accounts 10 + a 10.00$ 11 + b -10.00$
A
journal/printer/testdata/basic_twospaces.golden
··· 1 +2026-01-15 * Test restaurant 2 + ; header comment on transaction (before payee) 3 + Expenses:Food 50.00$ 4 + Assets:Cash -50.00$ 5 + ; inline comment on posting 6 + 7 +; A simple comment line 8 + 9 +2026-01-16 another entry 10 + Expenses:Food 30.00$ 11 + Assets:Cash -30.00$
A
journal/printer/testdata/directives.golden
··· 1 +; Directives 2 +account Assets:Cash ; cash account 3 +commodity $ 4 +include 5 +alias Assets:Checking = Assets:Cash 6 +payee "Test Payee" 7 +tag 8 +year 2026 9 +decimal-mark , 10 + 11 +; Market price 12 +P 2026-01-15 12:00:00 GOOG 150.00$ 13 + 14 +; Conversion 15 +C 100.00$ = 85.00€ ; test comment 16 + 17 +; Default commodity 18 +D 1,000.00$ 19 + 20 +; Apply/end 21 +apply account Expenses 22 +end 23 + 24 +; Comment block 25 +comment 26 +Some notes about the file 27 +end
A
journal/printer/testdata/directives.input
··· 1 +; Directives 2 +account Assets:Cash ; cash account 3 +commodity $ 4 +include "other.journal" 5 +alias Assets:Checking = Assets:Cash 6 +payee "Test Payee" 7 +tag "important" 8 +year 2026 9 +decimal-mark , 10 + 11 +; Market price 12 +P 2026/01/15 12:00:00 GOOG $150.00 13 + 14 +; Conversion 15 +C 100.00$ = 85.00€ ; test comment 16 + 17 +; Default commodity 18 +D $1,000.00 19 + 20 +; Apply/end 21 +apply account Expenses 22 +end 23 + 24 +; Comment block 25 +comment 26 +Some notes about the file 27 +end comment
A
journal/printer/testdata/entries.golden
··· 1 +; i am a comment 2 +;; i am a comment 3 +# i am a comment 4 +* i am a comment 5 +**** i'm a fat comment 6 +% and i am a comment 7 + 8 +2026-01-15 * Test restaurant 9 + ; header comment on transaction (before payee) 10 + Expenses:Food 50.00$ 11 + Assets:Cash -50.00$ 12 + ; inline comment on posting 13 + 14 +; A simple comment line 15 + 16 +2026-01-16 another entry 17 + Expenses:Food 30.00$ 18 + Assets:Cash -30.00$ 19 + 20 +~ monthly 21 + Expenses:Food 100.00$ 22 + Assets:Cash -100.00$ 23 + 24 += account:Expenses 25 + Expenses:Food 100.00$ 26 + Assets:Cash -100.00$ 27 + 28 +2026-01-15 * (CODE) Test with code and note | Some note 29 + Expenses:Food 100.00$ 30 + Assets:Cash -100.00$ 31 + 32 +2026-01-15=2026-01-20 * Virtual posting 33 + (Expenses:Food) 100.00$ 34 + [Assets:Cash] -100.00$ 35 + 36 +2026-01-15 * Cost test 37 + Expenses:Food 100.00$ @ 1.50$ 38 + Assets:Cash -100.00$ @@ 1.50$ 39 + ; continuation comment 40 + 41 +2026-02-01 * Balance assertion test 42 + Expenses:Food 50.00$ = 500.00$ 43 + Assets:Cash -50.00$ == 500.00$ 44 + Assets:Total === 0.00$ 45 + 46 +2026-03-01 Multi-posting 47 + Expenses:Food 30.00$ 48 + Expenses:Dining 20.00$ 49 + Assets:Cash -50.00$ 50 + 51 +0000-01-01 test 52 + a 123.00$ 53 + b 54 + 55 +2026-01-15 * Comments test 56 + ; header comment 57 + Expenses:Food 50.00$ 58 + Assets:Cash -50.00$ 59 + ; inline comment
A
journal/printer/testdata/entries.input
··· 1 +; i am a comment 2 +;; i am a comment 3 +# i am a comment 4 +* i am a comment 5 +**** i'm a fat comment 6 +% and i am a comment 7 + 8 +2026/01/15 * Test restaurant 9 + ; header comment on transaction (before payee) 10 + Expenses:Food $50.00 11 + Assets:Cash $-50.00 12 + ; inline comment on posting 13 + 14 +; A simple comment line 15 + 16 +2026/01/16 another entry 17 + Expenses:Food $30.00 18 + Assets:Cash $-30.00 19 + 20 +~ monthly 21 + Expenses:Food $100.00 22 + Assets:Cash $-100.00 23 + 24 += account:Expenses 25 + Expenses:Food $100.00 26 + Assets:Cash $-100.00 27 + 28 +2026/01/15 * (CODE) Test with code and note | Some note 29 + Expenses:Food $100 30 + Assets:Cash $-100 31 + 32 +2026/01/15=2026/01/20 * Virtual posting 33 + (Expenses:Food) $100.00 34 + [Assets:Cash] $-100.00 35 + 36 +2026/01/15 * Cost test 37 + Expenses:Food $100.00 @ $1.50 38 + Assets:Cash $-100.00 @@ $1.50 39 + ; continuation comment 40 + 41 +2026/02/01 * Balance assertion test 42 + Expenses:Food $50.00 = $500.00 43 + Assets:Cash $-50.00 == $500.00 44 + Assets:Total === $0.00 45 + 46 +2026/03/01 Multi-posting 47 + Expenses:Food $30.00 48 + Expenses:Dining $20.00 49 + Assets:Cash $-50.00 50 + 51 +01-01 test 52 + a $123.00 53 + b 54 + 55 +2026/01/15 * Comments test 56 + ; header comment 57 + Expenses:Food $50.00 58 + Assets:Cash $-50.00 59 + ; inline comment
A
journal/printer/testdata/sample.golden
··· 1 +; accounts {{{ 2 +account assets 3 +account assets:bank 4 +account assets:cash 5 +account equity 6 +account income 7 +account income:salary 8 +account income:invenstment 9 +account expenses 10 +account expenses:education 11 +; }}} 12 +; 01 Jan {{{ 13 +2026-01-01 opening balances 14 + assets:bank 123.00 USD 15 + assets:cash 1234.12 USD 16 + equity:open 17 + 18 +2026-01-04 selary 19 + income:salary 543.22$ 20 + assets:bank 21 +; }}} 22 +; 02 Feb {{{ 23 +2026-02-01 internet 24 + expenses:utilities 350.00 UAH 25 + assets:bank 26 + 27 +2026-02-10 * something | 2 months of *income* 28 + assets:bank 1.50 USD ; jan 29 + assets:bank 1.00 USD ; feb 30 + income:invenstment
A
journal/printer/testdata/sample.input
··· 1 +; accounts {{{ 2 +account assets 3 +account assets:bank 4 +account assets:cash 5 +account equity 6 +account income 7 +account income:salary 8 +account income:invenstment 9 +account expenses 10 +account expenses:education 11 +; }}} 12 +; 01 Jan {{{ 13 +2026-01-01 opening balances 14 + assets:bank 123.00 USD 15 + assets:cash 1234.12 USD 16 + equity:open 17 + 18 +2026-01-04 selary 19 + income:salary $543.22 20 + assets:bank 21 +; }}} 22 +; 02 Feb {{{ 23 +2026-02-01 internet 24 + expenses:utilities 350 UAH 25 + assets:bank 26 + 27 +2026-02-10 * something | 2 months of *income* 28 + assets:bank 1.50 USD ; jan 29 + assets:bank USD 1 ; feb 30 + income:invenstment
A
journal/printer/transaction.go
··· 1 +package printer 2 + 3 +import ( 4 + "olexsmir.xyz/clerk/journal/ast" 5 +) 6 + 7 +func (p *printer) writeTransaction(t *ast.Transaction) { 8 + // date 9 + p.writeDate(t.Date) 10 + 11 + // second date 12 + if t.SecondDate != nil { 13 + p.buf.WriteByte('=') 14 + p.writeDate(*t.SecondDate) 15 + } 16 + 17 + // status 18 + if t.Status != nil && t.Status.Value != ast.StatusNone { 19 + p.buf.WriteByte(' ') 20 + p.buf.WriteString(t.Status.Value.String()) 21 + } 22 + 23 + // code 24 + if t.Code != nil && *t.Code != "" { 25 + p.buf.WriteString(" (") 26 + p.buf.WriteString(*t.Code) 27 + p.buf.WriteByte(')') 28 + } 29 + 30 + // payee 31 + if t.Payee != nil && t.Payee.Name != "" { 32 + p.buf.WriteByte(' ') 33 + p.buf.WriteString(t.Payee.Name) 34 + } 35 + 36 + // note 37 + if t.Note != nil && *t.Note != "" { 38 + p.buf.WriteString(" | ") 39 + p.buf.WriteString(*t.Note) 40 + } 41 + 42 + p.writeComment(t.Comment) 43 + p.buf.WriteByte('\n') 44 + 45 + // header comments (between transaction line and first posting) 46 + for _, c := range t.HeaderComments { 47 + p.buf.WriteString(p.indent) 48 + p.writeComment(c) 49 + p.buf.WriteByte('\n') 50 + } 51 + 52 + // postings 53 + p.writePostings(t.Postings) 54 +} 55 + 56 +func (p *printer) writePeriodicTransaction(t *ast.PeriodicTransaction) { 57 + p.buf.WriteByte('~') 58 + 59 + // period 60 + if t.Period.Raw != "" { 61 + p.buf.WriteByte(' ') 62 + p.buf.WriteString(t.Period.Raw) 63 + } 64 + 65 + // status 66 + if t.Status != nil && t.Status.Value != ast.StatusNone { 67 + p.buf.WriteByte(' ') 68 + p.buf.WriteString(t.Status.Value.String()) 69 + } 70 + 71 + // code 72 + if t.Code != nil && *t.Code != "" { 73 + p.buf.WriteString(" (") 74 + p.buf.WriteString(*t.Code) 75 + p.buf.WriteByte(')') 76 + } 77 + 78 + // description 79 + if t.Description != nil && *t.Description != "" { 80 + p.buf.WriteByte(' ') 81 + p.buf.WriteString(*t.Description) 82 + } 83 + 84 + // comment 85 + p.writeInlineComment(t.Comment) 86 + p.buf.WriteByte('\n') 87 + 88 + // header comments (between periodic line and first posting) 89 + for _, c := range t.HeaderComments { 90 + p.buf.WriteString(p.indent) 91 + p.writeComment(c) 92 + p.buf.WriteByte('\n') 93 + } 94 + 95 + // postings 96 + p.writePostings(t.Postings) 97 +} 98 + 99 +func (p *printer) writeAutomatedTransaction(t *ast.AutomatedTransaction) { 100 + p.buf.WriteByte('=') 101 + 102 + // expression 103 + if t.Expr != "" { 104 + p.buf.WriteByte(' ') 105 + p.buf.WriteString(t.Expr) 106 + } 107 + 108 + // comment 109 + if t.Comment != nil && t.Comment.Text != "" { 110 + p.buf.WriteString(" ; ") 111 + p.buf.WriteString(t.Comment.Text) 112 + } 113 + 114 + p.buf.WriteByte('\n') 115 + 116 + // header comments (between auto line and first posting) 117 + for _, c := range t.HeaderComments { 118 + p.buf.WriteString(p.indent) 119 + p.writeComment(c) 120 + p.buf.WriteByte('\n') 121 + } 122 + 123 + // postings 124 + p.writePostings(t.Postings) 125 +} 126 + 127 +// writeDate writes date duh. 128 +// TODO: support dates like '02/12' 129 +func (p *printer) writeDate(d ast.Date) { 130 + p.writeInt(d.Year, 4) 131 + p.buf.WriteByte('-') 132 + p.writeInt(int(d.Month), 2) 133 + p.buf.WriteByte('-') 134 + p.writeInt(int(d.Day), 2) 135 +} 136 + 137 +func (p *printer) writeTime(t *ast.Time) { 138 + if t != nil { 139 + p.buf.WriteByte(' ') 140 + p.writeInt(t.Hour, 2) 141 + p.buf.WriteByte(':') 142 + p.writeInt(t.Minute, 2) 143 + p.buf.WriteByte(':') 144 + p.writeInt(t.Second, 2) 145 + } 146 +} 147 + 148 +// writeInt writes an integer left-padded to the given number of digits. 149 +func (p *printer) writeInt(n int, digits int) { 150 + var b [10]byte 151 + pos := len(b) 152 + for range digits { 153 + pos-- 154 + b[pos] = '0' + byte(n%10) 155 + n /= 10 156 + } 157 + p.buf.Write(b[pos:]) 158 +}
A
journal/printer/transaction_postings.go
··· 1 +package printer 2 + 3 +import ( 4 + "fmt" 5 + "strings" 6 + "text/tabwriter" 7 + 8 + "olexsmir.xyz/clerk/journal/ast" 9 +) 10 + 11 +func (p *printer) writePostings(postings []*ast.Posting) { 12 + if len(postings) == 0 { 13 + return 14 + } 15 + 16 + maxAcct := measureTxAccts(postings) 17 + if p.cfg.AlignStyle == AlignTwoSpaces { 18 + p.writePostingsTwoSpaces(postings, maxAcct) 19 + } else { 20 + p.writePostingsTabbed(postings, maxAcct) 21 + } 22 +} 23 + 24 +func (p *printer) writePostingsTwoSpaces(postings []*ast.Posting, maxAcct int) { 25 + for _, pt := range postings { 26 + p.writePostingLine(pt, maxAcct) 27 + p.buf.WriteByte('\n') 28 + for _, c := range pt.Comments { 29 + p.buf.WriteString(p.indent) 30 + p.writeComment(&c) 31 + p.buf.WriteByte('\n') 32 + } 33 + } 34 +} 35 + 36 +func (p *printer) writePostingsTabbed(postings []*ast.Posting, maxAcct int) { 37 + var tmp strings.Builder 38 + tw := tabwriter.NewWriter(&tmp, 0, 0, 2, ' ', tabwriter.StripEscape) 39 + 40 + lp := &printer{cfg: p.cfg, indent: p.indent} 41 + for _, pt := range postings { 42 + lp.buf.Reset() 43 + lp.writePostingLine(pt, maxAcct) 44 + fmt.Fprintln(tw, lp.buf.String()) 45 + for _, c := range pt.Comments { 46 + lp.buf.Reset() 47 + lp.buf.WriteString(lp.indent) 48 + lp.writeComment(&c) 49 + fmt.Fprintln(tw, lp.buf.String()) 50 + } 51 + } 52 + tw.Flush() 53 + 54 + out := strings.TrimRight(tmp.String(), "\n") 55 + if out == "" { 56 + return 57 + } 58 + p.buf.WriteString(out) 59 + p.buf.WriteByte('\n') 60 +} 61 + 62 +func (p *printer) writePostingLine(pt *ast.Posting, maxAcct int) { 63 + p.buf.WriteString(p.indent) 64 + 65 + if pt.Status != nil && pt.Status.Value != ast.StatusNone { 66 + p.buf.WriteString(pt.Status.Value.String()) 67 + p.buf.WriteByte(' ') 68 + } 69 + 70 + acct := pt.Account.Name 71 + switch pt.Type { 72 + case ast.PostingVirtualUnbalanced: 73 + acct = "(" + acct + ")" 74 + case ast.PostingVirtualBalanced: 75 + acct = "[" + acct + "]" 76 + } 77 + p.buf.WriteString(acct) 78 + 79 + pos := p.cfg.CommodityPos 80 + 81 + if pt.Amount != nil || pt.Balance != nil { 82 + switch p.cfg.AlignStyle { 83 + case AlignTwoSpaces: 84 + p.buf.WriteString(" ") 85 + case AlignRight: 86 + current := len(p.indent) + len(acct) 87 + if pt.Status != nil && pt.Status.Value != ast.StatusNone { 88 + current++ 89 + } 90 + if pad := p.cfg.AlignColumn - current; pad > 0 { 91 + p.writeSpaces(pad) 92 + } 93 + p.buf.WriteByte('\v') 94 + case AlignTab: 95 + current := len(p.indent) + len(acct) 96 + if pt.Status != nil && pt.Status.Value != ast.StatusNone { 97 + current++ 98 + } 99 + if pad := maxAcct + len(p.indent) - current; pad > 0 { 100 + p.writeSpaces(pad) 101 + } 102 + p.buf.WriteByte('\v') 103 + default: 104 + panic("impossible AlignStyle state") 105 + } 106 + } 107 + 108 + if pt.Amount != nil { 109 + p.writeAmount(pt.Amount, pos) 110 + if pt.Cost != nil { 111 + p.writeCost(pt.Cost, pos) 112 + } 113 + } 114 + 115 + if pt.Balance != nil { 116 + if pt.Amount != nil { 117 + p.buf.WriteByte(' ') 118 + } 119 + p.writeBalanceAssertion(pt.Balance, pos) 120 + } 121 + 122 + if pt.Comment != nil && pt.Comment.Text != "" { 123 + if p.cfg.AlignStyle == AlignTwoSpaces { 124 + p.buf.WriteString(" ") 125 + } else { 126 + p.buf.WriteByte('\v') 127 + } 128 + p.buf.WriteString("; ") 129 + p.buf.WriteString(pt.Comment.Text) 130 + } 131 +} 132 + 133 +func measureTxAccts(postings []*ast.Posting) int { 134 + maxAcct := 0 135 + for _, p := range postings { 136 + n := len(p.Account.Name) 137 + if p.Type == ast.PostingVirtualUnbalanced || p.Type == ast.PostingVirtualBalanced { 138 + n += 2 139 + } 140 + if n > maxAcct { 141 + maxAcct = n 142 + } 143 + } 144 + return maxAcct 145 +}
M
main.go
··· 12 12 } 13 13 14 14 switch os.Args[1] { 15 + case "fmt", "format": 16 + runFormat(os.Args[2:]) 15 17 case "tags": 16 18 runTags(os.Args[2:]) 17 19 default: ··· 24 26 fmt.Fprintf(os.Stderr, `Usage: clerk <command> [options] 25 27 26 28 Commands: 27 - tags Generate ctags-compatibletag file. 29 + tags Generate ctags-compatible tag file 30 + format Format journal files 28 31 `) 29 32 }