all repos

clerk @ 7be09366fcfbf45911f1d22c04232c0356f0add9

missing tooling for ledger/hledger
8 files changed, 291 insertions(+), 54 deletions(-)
lsp: date completion
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-08-17 14:46:05 +0300
Authored at: 2026-08-16 14:34:12 +0300
Change ID: vlmylqvyntwuwovqrykyswmuvpuosuom
Parent: b361473
M internal/analyzer/analyzer.go
···
        29
        29
         	TagNames  []string // unique tag names, sorted

      
        30
        30
         	TagValues []string // unique non-empty tag values across all tags, sorted

      
        31
        31
         

      
        32
        
        -	Dates       []ast.Date // unique transaction dats in sorted order

      
        33
        
        -	DateStrings []string   // same order as Dates, "YYYY-MM-DD"

      
        
        32
        +	Dates       []ast.Date // unique transaction dates, sorted

      
        
        33
        +	DateStrings []string   // same order as Dates, rendered as written

      
        34
        34
         

      
        35
        
        -	TransactionsCountByDate map[string]int                // counts transactions per date, keyed by [formatDate]

      
        
        35
        +	TransactionsCountByDate map[string]int                // counts transactions per date, keyed by [Date.String]

      
        36
        36
         	TransactionsByKey       map[string][]*ast.Transaction // groups transactions by [TxDuplicateKey] signature

      
        37
        37
         }

      
        38
        38
         

      
M internal/analyzer/build.go
···
        264
        264
         		if s != "" && !seen[s] {

      
        265
        265
         			seen[s] = true

      
        266
        266
         			a.Dates = append(a.Dates, tx.Date)

      
        267
        
        -			a.DateStrings = append(a.DateStrings, s)

      
        268
        267
         		}

      
        269
        268
         	}

      
        270
        269
         	sort.Slice(a.Dates, func(i, j int) bool {

      
        271
        270
         		return a.Dates[i].Compare(a.Dates[j]) < 0

      
        272
        271
         	})

      
        273
        
        -	sort.Strings(a.DateStrings)

      
        
        272
        +	a.DateStrings = make([]string, len(a.Dates))

      
        
        273
        +	for i, d := range a.Dates {

      
        
        274
        +		a.DateStrings[i] = d.String()

      
        
        275
        +	}

      
        274
        276
         }

      
        275
        277
         

      
        276
        278
         func (a *Analysis) collectTags() {

      
M internal/analyzer/testdata/include.txtar
···
        59
        59
           account expenses:food

      
        60
        60
         

      
        61
        61
         transaction keys (1):

      
        62
        
        -  2024-01-01|test|expenses:food,assets:checking,

      
        
        62
        +  2024/01/01|test|expenses:food,assets:checking,

      
        63
        63
         

      
        64
        64
         payee templates (1):

      
        65
        65
           test

      
M internal/analyzer/testdata/journal.txtar
···
        134
        134
           commodity EUR

      
        135
        135
         

      
        136
        136
         transaction keys (3):

      
        137
        
        -  2024-01-01|groceries|expenses:food,assets:checking,

      
        138
        
        -  2024-01-02|lunch|expenses:food,assets:cash,

      
        139
        
        -  2024-01-04|aws|expenses:food,assets:cash,

      
        
        137
        +  2024/01/01|groceries|expenses:food,assets:checking,

      
        
        138
        +  2024/01/02|lunch|expenses:food,assets:cash,

      
        
        139
        +  2024/01/04|aws|expenses:food,assets:cash,

      
        140
        140
         

      
        141
        141
         payee templates (3):

      
        142
        142
           aws

      
M internal/lsp/testdata/completion-contexts.txtar
···
        69
        69
             expenses:f^ood  $5

      
        70
        70
             assets:cash

      
        71
        71
         

      
        
        72
        +in^c

      
        
        73
        +20^

      
        
        74
        +

      
        
        75
        +

      
        
        76
        +2^024-01-05 Grocery Store

      
        
        77
        +    expenses:food  $10

      
        
        78
        +    assets:cash

      
        
        79
        +

      
        
        80
        +2024-01-02 Coffee Shop

      
        
        81
        +    expenses:food  $10

      
        
        82
        +    assets:cash

      
        
        83
        +

      
        
        84
        +2024-06-15 Grocery Store

      
        
        85
        +    expenses:food  $10

      
        
        86
        +    assets:cash

      
        
        87
        +

      
        
        88
        +2024/01/20 acme

      
        
        89
        +    expenses:food  $10

      
        
        90
        +    assets:cash

      
        
        91
        +

      
        
        92
        +6-10 grocer

      
        
        93
        +    expenses:food  $10

      
        
        94
        +    assets:cash

      
        
        95
        +

      
        
        96
        +2024-06-1^5 Coffee Shop

      
        
        97
        +    expenses:food  $10

      
        
        98
        +    assets:cash

      
        
        99
        +

      
        
        100
        +2024/0^1/20 Grocery Store

      
        
        101
        +    expenses:food  $10

      
        
        102
        +    assets:cash

      
        
        103
        +

      
        72
        104
         -- expect --

      
        73
        
        -19:0 directive ""

      
        74
        
        -  C

      
        75
        
        -  D

      
        76
        
        -  N

      
        77
        
        -  P

      
        78
        
        -  Y

      
        79
        
        -  account

      
        80
        
        -  alias

      
        81
        
        -  apply

      
        82
        
        -  commodity

      
        83
        
        -  decimal-mark

      
        84
        
        -  end

      
        85
        
        -  include

      
        86
        
        -  payee

      
        87
        
        -  tag

      
        88
        
        -  year

      
        
        105
        +19:0 date ""

      
        
        106
        +  today

      
        
        107
        +  yesterday

      
        
        108
        +  2 days ago

      
        
        109
        +  2024-06-15

      
        
        110
        +  2024/01/20

      
        
        111
        +  2024-01-16

      
        
        112
        +  2024-01-15

      
        
        113
        +  2024-01-05

      
        
        114
        +  2024-01-03

      
        
        115
        +  2024-01-02

      
        
        116
        +  2024-01-01

      
        
        117
        +  6-10

      
        89
        118
         21:4 account ""

      
        90
        119
           expenses:food

      
        91
        120
           assets:cash

      ···
        122
        151
           client

      
        123
        152
         35:10 tag "groc"

      
        124
        153
         37:22 payee "gro"

      
        
        154
        +  Grocery Store

      
        125
        155
           grocer

      
        126
        156
           grocery

      
        127
        157
           Grocery

      
        128
        
        -  Grocery Store

      
        129
        158
         38:25 payee "cof"

      
        130
        
        -  coffee

      
        131
        159
           Coffee Shop

      
        
        160
        +  coffee

      
        132
        161
         39:15 payee "gro"

      
        
        162
        +  Grocery Store

      
        133
        163
           grocer

      
        134
        164
           grocery

      
        135
        165
           Grocery

      
        136
        
        -  Grocery Store

      
        137
        166
         40:20 none ""

      
        138
        167
         41:22 none ""

      
        139
        168
         42:16 tag "cli"

      
        140
        169
           client

      
        141
        
        -43:10 payee ""

      
        142
        
        -  Grocery

      
        143
        
        -  Grocery Store

      
        144
        
        -  acme

      
        145
        
        -  acme|note xy

      
        146
        
        -  coffee

      
        147
        
        -  grocer

      
        148
        
        -  grocery

      
        149
        
        -  Coffee Shop

      
        
        170
        +43:10 date "2024-01-03"

      
        
        171
        +  2024-01-03

      
        150
        172
         46:6 none ""

      
        151
        173
         51:6 account "no"

      
        152
        174
           expenses:food

      
        153
        175
         56:6 none ""

      
        154
        176
         58:9 none ""

      
        155
        177
         66:11 payee ""

      
        156
        
        -  Grocery

      
        157
        178
           Grocery Store

      
        
        179
        +  Coffee Shop

      
        158
        180
           acme

      
        
        181
        +  grocer

      
        
        182
        +  Grocery

      
        159
        183
           acme|note xy

      
        160
        184
           coffee

      
        161
        
        -  grocer

      
        162
        185
           grocery

      
        163
        
        -  Coffee Shop

      
        164
        186
         67:14 account "expenses:f"

      
        165
        187
           expenses:food

      
        
        188
        +70:2 directive "in"

      
        
        189
        +  include

      
        
        190
        +71:2 date "20"

      
        
        191
        +  today

      
        
        192
        +  yesterday

      
        
        193
        +  2 days ago

      
        
        194
        +  2024-06-15

      
        
        195
        +  2024/01/20

      
        
        196
        +  2024-01-16

      
        
        197
        +  2024-01-15

      
        
        198
        +  2024-01-05

      
        
        199
        +  2024-01-03

      
        
        200
        +  2024-01-02

      
        
        201
        +  2024-01-01

      
        
        202
        +74:1 date "2"

      
        
        203
        +  today

      
        
        204
        +  yesterday

      
        
        205
        +  2 days ago

      
        
        206
        +  2024-06-15

      
        
        207
        +  2024/01/20

      
        
        208
        +  2024-01-16

      
        
        209
        +  2024-01-15

      
        
        210
        +  2024-01-05

      
        
        211
        +  2024-01-03

      
        
        212
        +  2024-01-02

      
        
        213
        +  2024-01-01

      
        
        214
        +94:9 date "2024-06-1"

      
        
        215
        +  2024-06-15

      
        
        216
        +98:6 date "2024/0"

      
        
        217
        +  2024/01/20

      
M internal/lsp/textdocument_completion.go
···
        6
        6
         	"math"

      
        7
        7
         	"sort"

      
        8
        8
         	"strings"

      
        
        9
        +	"time"

      
        9
        10
         

      
        10
        11
         	"go.lsp.dev/protocol"

      
        11
        12
         

      ···
        49
        50
         	cmplAccount

      
        50
        51
         	cmplPayee

      
        51
        52
         	cmplCommodity

      
        
        53
        +	cmplDate

      
        52
        54
         	cmplTagName

      
        53
        55
         	cmplTagValue

      
        54
        56
         	cmplDirective

      ···
        62
        64
         func detectCompletionCtx(content string, cursor int) (cmplCtx, int) {

      
        63
        65
         	toks := lexLine(content, cursor)

      
        64
        66
         	lineStart, _ := lineBounds(content, cursor)

      
        65
        
        -

      
        
        67
        +	if len(toks) == 0 {

      
        
        68
        +		return cmplDate, lineStart

      
        
        69
        +	}

      
        66
        70
         	if m := commentMarker(toks, cursor); m != -1 {

      
        67
        71
         		return cmplTagContext(content, toks[m].Span.End.Offset, cursor)

      
        68
        72
         	}

      
        69
        
        -	if len(toks) == 0 {

      
        70
        
        -		return cmplDirective, lineStart

      
        71
        
        -	}

      
        72
        73
         

      
        73
        74
         	switch toks[0].Type {

      
        74
        75
         	case token.INDENT:

      
        75
        76
         		return cmplPostingCtx(content, cursor, toks)

      
        76
        77
         	case token.DATE:

      
        77
        78
         		return cmplHeaderCtx(content, cursor, toks)

      
        
        79
        +	case token.ILLEGAL:

      
        
        80
        +		// a digit that is not lexed yet, as a date: 20, 2026-0

      
        
        81
        +		if len(toks[0].Literal) > 0 && isDigitByte(toks[0].Literal[0]) {

      
        
        82
        +			return cmplHeaderCtx(content, cursor, toks)

      
        
        83
        +		}

      
        
        84
        +		return cmplNone, cursor

      
        78
        85
         	case token.ACCOUNT, token.COMMODITY, token.PAYEE, token.TAG:

      
        79
        86
         		return cmplDirectiveContext(cursor, lineStart, toks)

      
        80
        87
         	case token.TEXT:

      
        81
        88
         		return cmplDirective, lineStart // half-typed keyword or unparseable line

      
        
        89
        +	default:

      
        
        90
        +		return cmplNone, cursor

      
        82
        91
         	}

      
        83
        
        -	return cmplNone, cursor

      
        84
        92
         }

      
        85
        93
         

      
        86
        94
         func cmplPostingCtx(content string, cursor int, toks []token.Token) (cmplCtx, int) {

      ···
        126
        134
         }

      
        127
        135
         

      
        128
        136
         func cmplHeaderCtx(content string, cursor int, toks []token.Token) (cmplCtx, int) {

      
        
        137
        +	// cursor inside date token

      
        
        138
        +	if cursor >= toks[0].Span.Start.Offset && cursor <= toks[0].Span.End.Offset {

      
        
        139
        +		return cmplDate, toks[0].Span.Start.Offset

      
        
        140
        +	}

      
        
        141
        +

      
        129
        142
         	// skip date, status, code, and whitespace - where the payee beginds

      
        130
        143
         	fieldStart := toks[0].Span.End.Offset

      
        131
        144
         	fieldEnd := fieldStart

      ···
        159
        172
         				seen = true

      
        160
        173
         			}

      
        161
        174
         			fieldEnd = t.Span.End.Offset

      
        162
        
        -		default:

      
        163
        
        -			// PIPE, SEMICOLON, ...

      
        
        175
        +		default: // PIPE, SEMICOLON, ...

      
        164
        176
         			return payeeAt(cursor, fieldStart, fieldEnd)

      
        165
        177
         		}

      
        166
        178
         	}

      ···
        295
        307
         	score        float64

      
        296
        308
         	count        int

      
        297
        309
         	lastUsedDays int64 // days since 1970-01-01; 0 when unset

      
        
        310
        +	rank         int   // lower sorts first among equal scores; 0 except for date completions

      
        298
        311
         }

      
        299
        312
         

      
        300
        313
         // cmplItems ranks candidates for the content against typed pattern

      ···
        342
        355
         		for _, name := range directiveKeywords {

      
        343
        356
         			cands = append(cands, cmplCand{label: name})

      
        344
        357
         		}

      
        
        358
        +	case cmplDate:

      
        
        359
        +		kind = protocol.CompletionItemKindConstant

      
        
        360
        +		now := time.Now()

      
        
        361
        +		sep, hasYear := dateStyle(a.DateStrings)

      
        
        362
        +		today := renderDate(now, sep, hasYear)

      
        
        363
        +		yesterday := renderDate(now.AddDate(0, 0, -1), sep, hasYear)

      
        
        364
        +		twoDaysAgo := renderDate(now.AddDate(0, 0, -2), sep, hasYear)

      
        
        365
        +		cands = append(cands,

      
        
        366
        +			cmplCand{label: today, rank: 0},

      
        
        367
        +			cmplCand{label: yesterday, rank: 1},

      
        
        368
        +			cmplCand{label: twoDaysAgo, rank: 2})

      
        
        369
        +

      
        
        370
        +		seen := map[string]bool{today: true, yesterday: true, twoDaysAgo: true}

      
        
        371
        +		for i, d := range a.Dates {

      
        
        372
        +			if !datePatternMatch(pattern, a.DateStrings[i]) {

      
        
        373
        +				continue

      
        
        374
        +			}

      
        
        375
        +			label := a.DateStrings[i]

      
        
        376
        +			if seen[label] {

      
        
        377
        +				continue

      
        
        378
        +			}

      
        
        379
        +			seen[label] = true

      
        
        380
        +			cands = append(cands, cmplCand{label: label, rank: 3, lastUsedDays: dateToDays(d)})

      
        
        381
        +		}

      
        345
        382
         	default:

      
        346
        383
         		return nil

      
        347
        384
         	}

      ···
        370
        407
         		if ranked[i].score != ranked[j].score {

      
        371
        408
         			return ranked[i].score > ranked[j].score

      
        372
        409
         		}

      
        
        410
        +		if ranked[i].rank != ranked[j].rank {

      
        
        411
        +			return ranked[i].rank < ranked[j].rank

      
        
        412
        +		}

      
        373
        413
         		if ranked[i].lastUsedDays != ranked[j].lastUsedDays {

      
        374
        414
         			return ranked[i].lastUsedDays > ranked[j].lastUsedDays

      
        375
        415
         		}

      ···
        379
        419
         		ranked = ranked[:maxCompletionItems]

      
        380
        420
         	}

      
        381
        421
         

      
        382
        
        -	replace := protocol.Range{

      
        383
        
        -		Start: li.Position(start),

      
        384
        
        -		End:   li.Position(cursor),

      
        
        422
        +	end := cursor

      
        
        423
        +	if ctx == cmplDate {

      
        
        424
        +		end = dateTokenEnd(content, start)

      
        385
        425
         	}

      
        386
        426
         	items := make([]protocol.CompletionItem, len(ranked))

      
        387
        427
         	for i, r := range ranked {

      ···
        391
        431
         			SortText:   protocol.NewOptional(fmt.Sprintf("%04d", i)),

      
        392
        432
         			FilterText: protocol.NewOptional(r.label),

      
        393
        433
         			TextEdit: &protocol.TextEdit{

      
        394
        
        -				Range:   replace,

      
        
        434
        +				Range: protocol.Range{

      
        
        435
        +					Start: li.Position(start),

      
        
        436
        +					End:   li.Position(end),

      
        
        437
        +				},

      
        395
        438
         				NewText: r.label,

      
        396
        439
         			},

      
        397
        440
         		}

      ···
        423
        466
         	doy := (153*mp+2)/5 + d - 1

      
        424
        467
         	doe := yoe*365 + yoe/4 - yoe/100 + doy

      
        425
        468
         	return int64(era)*146097 + int64(doe) - 719468

      
        
        469
        +}

      
        
        470
        +

      
        
        471
        +// dateStyle returns reparator and yesr-ness of the most recent history date.

      
        
        472
        +// Defualts to '-'/true when history is empty.

      
        
        473
        +func dateStyle(history []string) (sep byte, hasYear bool) {

      
        
        474
        +	sep, hasYear = '-', true

      
        
        475
        +	if n := len(history); n > 0 {

      
        
        476
        +		if i := strings.IndexAny(history[n-1], "-/."); i >= 0 {

      
        
        477
        +			return history[n-1][i], i == 4

      
        
        478
        +		}

      
        
        479
        +	}

      
        
        480
        +	return

      
        
        481
        +}

      
        
        482
        +

      
        
        483
        +func renderDate(t time.Time, sep byte, hasYear bool) string {

      
        
        484
        +	d := ast.Date{Month: int(t.Month()), Day: t.Day(), Sep: sep}

      
        
        485
        +	if hasYear {

      
        
        486
        +		d.Year = t.Year()

      
        
        487
        +	}

      
        
        488
        +	return d.String()

      
        
        489
        +}

      
        
        490
        +

      
        
        491
        +func datePatternMatch(pattern, canonical string) bool {

      
        
        492
        +	pi, ci := 0, 0

      
        
        493
        +	for pi < len(pattern) && ci < len(canonical) {

      
        
        494
        +		if pattern[pi] != canonical[ci] {

      
        
        495
        +			ci++

      
        
        496
        +			continue

      
        
        497
        +		}

      
        
        498
        +		pi++

      
        
        499
        +		ci++

      
        
        500
        +	}

      
        
        501
        +	return pi == len(pattern)

      
        
        502
        +}

      
        
        503
        +

      
        
        504
        +func isDateSepByte(b byte) bool { return b == '-' || b == '/' || b == '.' }

      
        
        505
        +func isDigitByte(b byte) bool   { return b >= '0' && b <= '9' }

      
        
        506
        +

      
        
        507
        +func dateTokenEnd(content string, start int) int {

      
        
        508
        +	i := start

      
        
        509
        +	for i < len(content) && (isDigitByte(content[i]) || isDateSepByte(content[i])) {

      
        
        510
        +		i++

      
        
        511
        +	}

      
        
        512
        +	return i

      
        426
        513
         }

      
        427
        514
         

      
        428
        515
         // lineBounds returns the byte offsets of the line containing cursor

      
M internal/lsp/textdocument_completion_test.go
···
        25
        25
         	}

      
        26
        26
         }

      
        27
        27
         

      
        
        28
        +func TestDateStyle(t *testing.T) {

      
        
        29
        +	tests := map[string]struct {

      
        
        30
        +		history []string

      
        
        31
        +		sep     byte

      
        
        32
        +		hasYear bool

      
        
        33
        +	}{

      
        
        34
        +		"empty":    {nil, '-', true},

      
        
        35
        +		"dash":     {[]string{"2024-01-02"}, '-', true},

      
        
        36
        +		"slash":    {[]string{"2024/01/20"}, '/', true},

      
        
        37
        +		"yearless": {[]string{"6-10"}, '-', false},

      
        
        38
        +	}

      
        
        39
        +	for name, tt := range tests {

      
        
        40
        +		t.Run(name, func(t *testing.T) {

      
        
        41
        +			sep, hasYear := dateStyle(tt.history)

      
        
        42
        +			if sep != tt.sep || hasYear != tt.hasYear {

      
        
        43
        +				t.Errorf("dateStyle = %q/%v, want %q/%v", sep, hasYear, tt.sep, tt.hasYear)

      
        
        44
        +			}

      
        
        45
        +		})

      
        
        46
        +	}

      
        
        47
        +}

      
        
        48
        +

      
        
        49
        +func TestDateTokenEnd(t *testing.T) {

      
        
        50
        +	tests := map[string]struct {

      
        
        51
        +		content string

      
        
        52
        +		start   int

      
        
        53
        +		want    int

      
        
        54
        +	}{

      
        
        55
        +		"full date":            {"2024-01-02 x", 0, 10},

      
        
        56
        +		"unpadded":             {"2024/1/2 x", 0, 8},

      
        
        57
        +		"yearless":             {"6-10 x", 0, 4},

      
        
        58
        +		"stops at =":           {"2024-01-02=2024-01-03", 0, 10},

      
        
        59
        +		"stops at comment":     {"2024-01-02;x", 0, 10},

      
        
        60
        +		"partial trailing sep": {"2024- x", 0, 5},

      
        
        61
        +		"partial mid":          {"2024-0 x", 0, 6},

      
        
        62
        +	}

      
        
        63
        +	for name, tt := range tests {

      
        
        64
        +		t.Run(name, func(t *testing.T) {

      
        
        65
        +			if got := dateTokenEnd(tt.content, tt.start); got != tt.want {

      
        
        66
        +				t.Errorf("dateTokenEnd = %d, want %d", got, tt.want)

      
        
        67
        +			}

      
        
        68
        +		})

      
        
        69
        +	}

      
        
        70
        +}

      
        
        71
        +

      
        
        72
        +func TestDatePatternMatch(t *testing.T) {

      
        
        73
        +	tests := map[string]struct {

      
        
        74
        +		pattern, canonical string

      
        
        75
        +		want               bool

      
        
        76
        +	}{

      
        
        77
        +		"digit subsequence":   {"2024-01-0", "2024-01-05", true},

      
        
        78
        +		"same sep":            {"2024/0", "2024/01/20", true},

      
        
        79
        +		"other sep rejected":  {"2024/0", "2024-01-20", false},

      
        
        80
        +		"yearless":            {"6-10", "6-10", true},

      
        
        81
        +		"yearless padded rej": {"06-10", "6-10", false},

      
        
        82
        +		"no match":            {"2025", "2024-01-20", false},

      
        
        83
        +		"empty":               {"", "2024-01-20", true},

      
        
        84
        +	}

      
        
        85
        +	for name, tt := range tests {

      
        
        86
        +		t.Run(name, func(t *testing.T) {

      
        
        87
        +			if got := datePatternMatch(tt.pattern, tt.canonical); got != tt.want {

      
        
        88
        +				t.Errorf("datePatternMatch = %v, want %v", got, tt.want)

      
        
        89
        +			}

      
        
        90
        +		})

      
        
        91
        +	}

      
        
        92
        +}

      
        
        93
        +

      
        28
        94
         func TestGolden_Completion(t *testing.T) {

      
        29
        95
         	for _, tt := range []string{"completion-contexts", "completion-journal", "completion-no-transactions"} {

      
        30
        96
         		t.Run(tt, func(t *testing.T) {

      ···
        46
        112
         				if !ok {

      
        47
        113
         					t.Fatalf("Completion returned %T, want *protocol.CompletionList", res)

      
        48
        114
         				}

      
        
        115
        +				now := time.Now()

      
        
        116
        +				var sep byte

      
        
        117
        +				var hasYear bool

      
        
        118
        +				if ctx == cmplDate {

      
        
        119
        +					if an := h.srv.analysisFor(h.uri); an != nil {

      
        
        120
        +						sep, hasYear = dateStyle(an.DateStrings)

      
        
        121
        +					}

      
        
        122
        +				}

      
        49
        123
         				for _, item := range list.Items {

      
        50
        
        -					fmt.Fprintf(&b, "  %s\n", item.Label)

      
        
        124
        +					label := item.Label

      
        
        125
        +					if ctx == cmplDate {

      
        
        126
        +						switch label {

      
        
        127
        +						case renderDate(now, sep, hasYear):

      
        
        128
        +							label = "today"

      
        
        129
        +						case renderDate(now.AddDate(0, 0, -1), sep, hasYear):

      
        
        130
        +							label = "yesterday"

      
        
        131
        +						case renderDate(now.AddDate(0, 0, -2), sep, hasYear):

      
        
        132
        +							label = "2 days ago"

      
        
        133
        +						}

      
        
        134
        +					}

      
        
        135
        +					fmt.Fprintf(&b, "  %s\n", label)

      
        51
        136
         				}

      
        52
        137
         			}

      
        53
        138
         			golden.Assert(t, ar, b.String())

      ···
        69
        154
         		return "tag"

      
        70
        155
         	case cmplTagValue:

      
        71
        156
         		return "tag-value"

      
        
        157
        +	case cmplDate:

      
        
        158
        +		return "date"

      
        72
        159
         	case cmplDirective:

      
        73
        160
         		return "directive"

      
        74
        161
         	default:

      ···
        92
        179
         		"1k txns, account":     strings.Index(content, "\n  1:2:3 ") + len("\n  ") + 2,

      
        93
        180
         		"1k txns, empty payee": strings.Index(content, "transaction 1") + len("transaction "),

      
        94
        181
         		"1k txns, commodity":   strings.Index(content, "2 B @@") + len("2 B"),

      
        
        182
        +		"1k txns, date":        strings.Index(content, "2000-01-0") + len("2000-01-0"),

      
        95
        183
         	} {

      
        96
        184
         		b.Run(tname, func(b *testing.B) {

      
        97
        185
         			line, col := lsputil.LineCol(content, tt)

      
M journal/ast/ast.go
···
        56
        56
         	return 0

      
        57
        57
         }

      
        58
        58
         

      
        59
        
        -// String renders d as "YYYY-MM-DD", or "" for an unset or invalid date.

      
        
        59
        +// String renders d as written: with its own separator, and without the year

      
        
        60
        +// when the date carried none. Returns "" for an unset or invalid date.

      
        60
        61
         func (d Date) String() string {

      
        61
        62
         	if (d.Year == 0 && d.Month == 0 && d.Day == 0) ||

      
        62
        63
         		(d.Month < 1 || d.Month > 12 || d.Day < 1 || d.Day > 31) {

      
        63
        64
         		return ""

      
        64
        65
         	}

      
        65
        
        -	return fmt.Sprintf("%04d-%02d-%02d", d.Year, d.Month, d.Day)

      
        
        66
        +	sep := d.Sep

      
        
        67
        +	if sep == 0 {

      
        
        68
        +		sep = '-'

      
        
        69
        +	}

      
        
        70
        +	if d.Year == 0 {

      
        
        71
        +		return fmt.Sprintf("%d%c%d", d.Month, sep, d.Day)

      
        
        72
        +	}

      
        
        73
        +	return fmt.Sprintf("%04d%c%02d%c%02d", d.Year, sep, d.Month, sep, d.Day)

      
        66
        74
         }

      
        67
        75
         

      
        68
        76
         type Time struct {