all repos

clerk @ 8f83d37

missing tooling for ledger/hledger
30 files changed, 667 insertions(+), 131 deletions(-)
journal: tags support
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-08-06 16:47:26 +0300
Authored at: 2026-08-05 15:12:14 +0300
Change ID: pozmovzsonumnprqqvwtlqumvkkzplvy
Parent: 9b2ce6b
M internal/analyzer/analyzer.go
···
        24
        24
         	// PayeeTemplates holds the last transaction's postings per payee name.

      
        25
        25
         	PayeeTemplates map[string][]PostingTemplate

      
        26
        26
         

      
        27
        
        -	TagNames []string // unique, from tag directives

      
        
        27
        +	Tags      map[string]*TagInfo

      
        
        28
        +	TagNames  []string // unique tag names, sorted

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

      
        28
        30
         

      
        29
        31
         	Dates       []ast.Date // unique transaction dats in sorted order

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

      ···
        70
        72
         type PayeeUsage struct {

      
        71
        73
         	FileIndex int

      
        72
        74
         	Payee     *ast.Payee

      
        
        75
        +}

      
        
        76
        +

      
        
        77
        +type TagInfo struct {

      
        
        78
        +	Directives []*ast.TagDirective

      
        
        79
        +	Usage      []TagUsage

      
        
        80
        +	UsedCount  int

      
        
        81
        +	LastUsed   ast.Date

      
        
        82
        +	Values     []string // unique non-empty values from comment usages, sorted

      
        
        83
        +}

      
        
        84
        +

      
        
        85
        +type TagUsage struct {

      
        
        86
        +	FileIndex int

      
        
        87
        +	Tag       *ast.Tag

      
        73
        88
         }

      
        74
        89
         

      
        75
        90
         type PostingTemplate struct {

      
M internal/analyzer/analyzer_test.go
···
        238
        238
         		fmt.Fprintf(w, "  %d-%02d-%02d\n", d.Year, d.Month, d.Day)

      
        239
        239
         	}

      
        240
        240
         

      
        
        241
        +	// tags

      
        
        242
        +	tnames := make([]string, 0, len(a.Tags))

      
        
        243
        +	for name := range a.Tags {

      
        
        244
        +		tnames = append(tnames, name)

      
        
        245
        +	}

      
        
        246
        +	sort.Strings(tnames)

      
        
        247
        +

      
        
        248
        +	fmt.Fprintf(w, "\ntags (%d):\n", len(a.Tags))

      
        
        249
        +	for _, name := range tnames {

      
        
        250
        +		info := a.Tags[name]

      
        
        251
        +		fmt.Fprintf(w, "  %s\n", name)

      
        
        252
        +		fmt.Fprintf(w, "    directives: %d\n", len(info.Directives))

      
        
        253
        +		fmt.Fprintf(w, "    used: %d\n", info.UsedCount)

      
        
        254
        +		for _, d := range info.Directives {

      
        
        255
        +			fmt.Fprintf(w, "      tag %s\n", d.Name)

      
        
        256
        +		}

      
        
        257
        +		fmt.Fprintf(w, "    usages: %d\n", len(info.Usage))

      
        
        258
        +		for _, u := range info.Usage {

      
        
        259
        +			fmt.Fprintf(w, "      file %d: %s=%q\n", u.FileIndex, u.Tag.Key, u.Tag.Value)

      
        
        260
        +		}

      
        
        261
        +		if len(info.Values) > 0 {

      
        
        262
        +			fmt.Fprintf(w, "    values: %s\n", strings.Join(info.Values, " "))

      
        
        263
        +		}

      
        
        264
        +		if info.LastUsed.Year != 0 {

      
        
        265
        +			fmt.Fprintf(w, "    last-used: %d-%02d-%02d\n", info.LastUsed.Year, info.LastUsed.Month, info.LastUsed.Day)

      
        
        266
        +		}

      
        
        267
        +	}

      
        
        268
        +

      
        241
        269
         	// tag names

      
        242
        270
         	fmt.Fprintf(w, "\ntag names (%d):\n", len(a.TagNames))

      
        243
        271
         	for _, t := range a.TagNames {

      
        244
        272
         		fmt.Fprintf(w, "  %s\n", t)

      
        
        273
        +	}

      
        
        274
        +

      
        
        275
        +	// tag values

      
        
        276
        +	fmt.Fprintf(w, "\ntag values (%d):\n", len(a.TagValues))

      
        
        277
        +	for _, v := range a.TagValues {

      
        
        278
        +		fmt.Fprintf(w, "  %s\n", v)

      
        245
        279
         	}

      
        246
        280
         

      
        247
        281
         	// commodity decimal marks

      
M internal/analyzer/build.go
···
        21
        21
         		Accounts:              make(map[string]*AccountInfo),

      
        22
        22
         		Commodities:           make(map[string]*CommodityInfo),

      
        23
        23
         		Payees:                make(map[string]*PayeeInfo),

      
        
        24
        +		Tags:                  make(map[string]*TagInfo),

      
        24
        25
         		AccountsByPrefix:      make(map[string][]string),

      
        25
        26
         		PayeeTemplates:        make(map[string][]PostingTemplate),

      
        26
        27
         		CommodityDecimalMarks: make(map[string]byte),

      ···
        37
        38
         	a.sortAccountNames()

      
        38
        39
         	a.collectPayeeNames()

      
        39
        40
         	a.collectDates()

      
        40
        
        -	a.collectTagNames()

      
        
        41
        +	a.collectTags()

      
        41
        42
         	return a

      
        42
        43
         }

      
        43
        44
         

      ···
        63
        64
         		a.addCommodityDirective(e)

      
        64
        65
         	case *ast.PayeeDirective:

      
        65
        66
         		a.addPayeeDirective(e)

      
        
        67
        +	case *ast.TagDirective:

      
        
        68
        +		a.addTagDirective(e)

      
        
        69
        +	case *ast.Comment:

      
        
        70
        +		a.addCommentTags(fileIndex, nil, e)

      
        66
        71
         	case *ast.Transaction:

      
        67
        72
         		a.Transactions = append(a.Transactions, e)

      
        68
        73
         		a.addPostings(fileIndex, e.Postings, &e.Date)

      
        69
        74
         		a.addPayee(fileIndex, e.Payee)

      
        70
        75
         		a.addPayeeTemplate(e)

      
        
        76
        +		a.addCommentTags(fileIndex, &e.Date, e.Comment)

      
        
        77
        +		for _, c := range e.HeaderComments {

      
        
        78
        +			a.addCommentTags(fileIndex, &e.Date, c)

      
        
        79
        +		}

      
        71
        80
         		key := TxDuplicateKey(e)

      
        72
        81
         		a.TransactionsByKey[key] = append(a.TransactionsByKey[key], e)

      
        73
        82
         	case *ast.PeriodicTransaction:

      
        74
        83
         		a.PeriodicTransactions = append(a.PeriodicTransactions, e)

      
        75
        84
         		a.addPostings(fileIndex, e.Postings, nil)

      
        
        85
        +		a.addCommentTags(fileIndex, nil, e.Comment)

      
        
        86
        +		for _, c := range e.HeaderComments {

      
        
        87
        +			a.addCommentTags(fileIndex, nil, c)

      
        
        88
        +		}

      
        76
        89
         	case *ast.AutomatedTransaction:

      
        77
        90
         		a.AutomatedTransactions = append(a.AutomatedTransactions, e)

      
        78
        91
         		a.addPostings(fileIndex, e.Postings, nil)

      
        79
        
        -	case *ast.DecimalMarkDirective:

      
        80
        
        -		// Collect commodity decimal marks from directives with explicit format,

      
        81
        
        -		// but DecimalMarkDirective sets the default for all commodities.

      
        82
        
        -		// We don't track a "default" — individual commodities get theirs from amount formatting.

      
        
        92
        +		a.addCommentTags(fileIndex, nil, e.Comment)

      
        
        93
        +		for _, c := range e.HeaderComments {

      
        
        94
        +			a.addCommentTags(fileIndex, nil, c)

      
        
        95
        +		}

      
        83
        96
         	case *ast.DefaultCommodityDirective:

      
        84
        97
         		if e.Amount.Commodity != "" {

      
        85
        98
         			mark := e.Amount.QuantityFmt.Decimal

      ···
        120
        133
         	})

      
        121
        134
         }

      
        122
        135
         

      
        
        136
        +func (a *Analysis) addTagDirective(td *ast.TagDirective) {

      
        
        137
        +	if td.Name == "" {

      
        
        138
        +		return

      
        
        139
        +	}

      
        
        140
        +	info, ok := a.Tags[td.Name]

      
        
        141
        +	if !ok {

      
        
        142
        +		info = &TagInfo{}

      
        
        143
        +		a.Tags[td.Name] = info

      
        
        144
        +	}

      
        
        145
        +	info.Directives = append(info.Directives, td)

      
        
        146
        +}

      
        
        147
        +

      
        
        148
        +func (a *Analysis) addCommentTags(fileIndex int, date *ast.Date, c *ast.Comment) {

      
        
        149
        +	if c == nil {

      
        
        150
        +		return

      
        
        151
        +	}

      
        
        152
        +	for i := range c.Tags {

      
        
        153
        +		t := &c.Tags[i]

      
        
        154
        +		info, ok := a.Tags[t.Key]

      
        
        155
        +		if !ok {

      
        
        156
        +			info = &TagInfo{}

      
        
        157
        +			a.Tags[t.Key] = info

      
        
        158
        +		}

      
        
        159
        +		info.Usage = append(info.Usage, TagUsage{FileIndex: fileIndex, Tag: t})

      
        
        160
        +		info.UsedCount++

      
        
        161
        +		if date != nil {

      
        
        162
        +			info.LastUsed = maxDate(info.LastUsed, *date)

      
        
        163
        +		}

      
        
        164
        +	}

      
        
        165
        +}

      
        
        166
        +

      
        123
        167
         func (a *Analysis) addCommodityDirective(cd *ast.CommodityDirective) {

      
        124
        168
         	info, ok := a.Commodities[cd.Commodity]

      
        125
        169
         	if !ok {

      ···
        171
        215
         			if posting.Balance.Cost != nil {

      
        172
        216
         				a.addCommodityUsage(fileIndex, &posting.Balance.Cost.Amount, date)

      
        173
        217
         			}

      
        
        218
        +		}

      
        
        219
        +

      
        
        220
        +		a.addCommentTags(fileIndex, date, posting.Comment)

      
        
        221
        +		for i := range posting.Comments {

      
        
        222
        +			a.addCommentTags(fileIndex, date, &posting.Comments[i])

      
        174
        223
         		}

      
        175
        224
         

      
        176
        225
         		// Collect decimal mark from amount formatting.

      ···
        222
        271
         	sort.Strings(a.DateStrings)

      
        223
        272
         }

      
        224
        273
         

      
        225
        
        -func (a *Analysis) collectTagNames() {

      
        226
        
        -	seen := make(map[string]bool)

      
        227
        
        -	for _, d := range a.Directives {

      
        228
        
        -		if td, ok := d.(*ast.TagDirective); ok && td.Name != "" && !seen[td.Name] {

      
        229
        
        -			seen[td.Name] = true

      
        230
        
        -			a.TagNames = append(a.TagNames, td.Name)

      
        
        274
        +func (a *Analysis) collectTags() {

      
        
        275
        +	names := make([]string, 0, len(a.Tags))

      
        
        276
        +	values := make(map[string]bool)

      
        
        277
        +	for name, info := range a.Tags {

      
        
        278
        +		names = append(names, name)

      
        
        279
        +		seen := make(map[string]bool)

      
        
        280
        +		for _, u := range info.Usage {

      
        
        281
        +			if u.Tag.Value == "" {

      
        
        282
        +				continue

      
        
        283
        +			}

      
        
        284
        +			seen[u.Tag.Value] = true

      
        
        285
        +			values[u.Tag.Value] = true

      
        
        286
        +		}

      
        
        287
        +		if len(seen) > 0 {

      
        
        288
        +			info.Values = sortedKeys(seen)

      
        231
        289
         		}

      
        232
        290
         	}

      
        233
        
        -	sort.Strings(a.TagNames)

      
        
        291
        +	sort.Strings(names)

      
        
        292
        +	a.TagNames = names

      
        
        293
        +	a.TagValues = sortedKeys(values)

      
        
        294
        +}

      
        
        295
        +

      
        
        296
        +func sortedKeys(m map[string]bool) []string {

      
        
        297
        +	out := make([]string, 0, len(m))

      
        
        298
        +	for k := range m {

      
        
        299
        +		out = append(out, k)

      
        
        300
        +	}

      
        
        301
        +	sort.Strings(out)

      
        
        302
        +	return out

      
        234
        303
         }

      
        235
        304
         

      
        236
        305
         func formatDate(d ast.Date) string {

      
M internal/analyzer/testdata/account-indexing.txtar
···
        100
        100
           2024-01-01

      
        101
        101
           2024-01-02

      
        102
        102
         

      
        
        103
        +tags (0):

      
        
        104
        +

      
        103
        105
         tag names (0):

      
        
        106
        +

      
        
        107
        +tag values (0):

      
        104
        108
         

      
        105
        109
         commodity decimal marks (1):

      
        106
        110
           $: .

      
M internal/analyzer/testdata/account-usage.txtar
···
        93
        93
           2024-01-02

      
        94
        94
           2024-01-15

      
        95
        95
         

      
        
        96
        +tags (0):

      
        
        97
        +

      
        96
        98
         tag names (0):

      
        
        99
        +

      
        
        100
        +tag values (0):

      
        97
        101
         

      
        98
        102
         commodity decimal marks (1):

      
        99
        103
           $: .

      
M internal/analyzer/testdata/commodity-usage.txtar
···
        103
        103
           2024-01-02

      
        104
        104
           2024-01-03

      
        105
        105
         

      
        
        106
        +tags (0):

      
        
        107
        +

      
        106
        108
         tag names (0):

      
        
        109
        +

      
        
        110
        +tag values (0):

      
        107
        111
         

      
        108
        112
         commodity decimal marks (3):

      
        109
        113
           $: .

      
M internal/analyzer/testdata/cost-balance.txtar
···
        69
        69
         dates (1):

      
        70
        70
           2024-01-01

      
        71
        71
         

      
        
        72
        +tags (0):

      
        
        73
        +

      
        72
        74
         tag names (0):

      
        
        75
        +

      
        
        76
        +tag values (0):

      
        73
        77
         

      
        74
        78
         commodity decimal marks (2):

      
        75
        79
           $: .

      
M internal/analyzer/testdata/dates.txtar
···
        109
        109
           2024-01-02

      
        110
        110
           2024-01-03

      
        111
        111
         

      
        
        112
        +tags (0):

      
        
        113
        +

      
        112
        114
         tag names (0):

      
        
        115
        +

      
        
        116
        +tag values (0):

      
        113
        117
         

      
        114
        118
         commodity decimal marks (1):

      
        115
        119
           $: .

      
M internal/analyzer/testdata/decimal-marks.txtar
···
        62
        62
         dates (1):

      
        63
        63
           2024-01-01

      
        64
        64
         

      
        
        65
        +tags (0):

      
        
        66
        +

      
        65
        67
         tag names (0):

      
        
        68
        +

      
        
        69
        +tag values (0):

      
        66
        70
         

      
        67
        71
         commodity decimal marks (1):

      
        68
        72
           $: .

      
M internal/analyzer/testdata/directives.txtar
···
        68
        68
         

      
        69
        69
         dates (0):

      
        70
        70
         

      
        
        71
        +tags (1):

      
        
        72
        +  mytag

      
        
        73
        +    directives: 1

      
        
        74
        +    used: 0

      
        
        75
        +      tag mytag

      
        
        76
        +    usages: 0

      
        
        77
        +

      
        71
        78
         tag names (1):

      
        72
        79
           mytag

      
        
        80
        +

      
        
        81
        +tag values (0):

      
        73
        82
         

      
        74
        83
         commodity decimal marks (1):

      
        75
        84
           $: .

      
M internal/analyzer/testdata/duplicate-transactions.txtar
···
        67
        67
         dates (1):

      
        68
        68
           2024-01-01

      
        69
        69
         

      
        
        70
        +tags (0):

      
        
        71
        +

      
        70
        72
         tag names (0):

      
        
        73
        +

      
        
        74
        +tag values (0):

      
        71
        75
         

      
        72
        76
         commodity decimal marks (1):

      
        73
        77
           $: .

      
M internal/analyzer/testdata/empty.txtar
···
        24
        24
         

      
        25
        25
         dates (0):

      
        26
        26
         

      
        
        27
        +tags (0):

      
        
        28
        +

      
        27
        29
         tag names (0):

      
        
        30
        +

      
        
        31
        +tag values (0):

      
        28
        32
         

      
        29
        33
         commodity decimal marks (0):

      
M internal/analyzer/testdata/include.txtar
···
        67
        67
         dates (1):

      
        68
        68
           2024-01-01

      
        69
        69
         

      
        
        70
        +tags (0):

      
        
        71
        +

      
        70
        72
         tag names (0):

      
        
        73
        +

      
        
        74
        +tag values (0):

      
        71
        75
         

      
        72
        76
         commodity decimal marks (1):

      
        73
        77
           $: .

      
M internal/analyzer/testdata/journal.txtar
···
        152
        152
           2024-01-02

      
        153
        153
           2024-01-04

      
        154
        154
         

      
        
        155
        +tags (0):

      
        
        156
        +

      
        155
        157
         tag names (0):

      
        
        158
        +

      
        
        159
        +tag values (0):

      
        156
        160
         

      
        157
        161
         commodity decimal marks (2):

      
        158
        162
           $: .

      
M internal/analyzer/testdata/payees.txtar
···
        92
        92
           2024-01-02

      
        93
        93
           2024-01-03

      
        94
        94
         

      
        
        95
        +tags (0):

      
        
        96
        +

      
        95
        97
         tag names (0):

      
        
        98
        +

      
        
        99
        +tag values (0):

      
        96
        100
         

      
        97
        101
         commodity decimal marks (1):

      
        98
        102
           $: .

      
M internal/analyzer/testdata/periodic-automated.txtar
···
        53
        53
         

      
        54
        54
         dates (0):

      
        55
        55
         

      
        
        56
        +tags (0):

      
        
        57
        +

      
        56
        58
         tag names (0):

      
        
        59
        +

      
        
        60
        +tag values (0):

      
        57
        61
         

      
        58
        62
         commodity decimal marks (1):

      
        59
        63
           $: .

      
M internal/analyzer/testdata/posting-types.txtar
···
        70
        70
         dates (1):

      
        71
        71
           2024-01-01

      
        72
        72
         

      
        
        73
        +tags (0):

      
        
        74
        +

      
        73
        75
         tag names (0):

      
        
        76
        +

      
        
        77
        +tag values (0):

      
        74
        78
         

      
        75
        79
         commodity decimal marks (1):

      
        76
        80
           $: .

      
M internal/analyzer/testdata/tags.txtar
···
        3
        3
         tag personal

      
        4
        4
         tag project-xyz

      
        5
        5
         

      
        
        6
        +2024-01-15 Client work  ; project:project-xyz, billed:

      
        
        7
        +    expenses:consulting  $100  ; rate:200

      
        
        8
        +    assets:receivable

      
        
        9
        +; reviewed:2024-02-01

      
        
        10
        +

      
        
        11
        +2024-02-03 Client work  ; billed:extra

      
        
        12
        +    expenses:consulting  $50

      
        
        13
        +    assets:receivable

      
        6
        14
         -- expect --

      
        7
        15
         files (1):

      
        8
        16
           0: in.journal

      
        9
        17
         

      
        10
        
        -commodities (0):

      
        
        18
        +commodities (1):

      
        
        19
        +  $

      
        
        20
        +    directives: 0

      
        
        21
        +    used: 2

      
        
        22
        +    usages: 2

      
        
        23
        +      file 0: $ ("100")

      
        
        24
        +      file 0: $ ("50")

      
        
        25
        +    last-used: 2024-02-03

      
        11
        26
         

      
        12
        
        -accounts (0):

      
        
        27
        +accounts (2):

      
        
        28
        +  assets:receivable

      
        
        29
        +    directives: 0

      
        
        30
        +    used: 2

      
        
        31
        +    usages: 2

      
        
        32
        +      file 0: assets:receivable

      
        
        33
        +      file 0: assets:receivable

      
        
        34
        +    last-used: 2024-02-03

      
        
        35
        +  expenses:consulting

      
        
        36
        +    directives: 0

      
        
        37
        +    used: 2

      
        
        38
        +    usages: 2

      
        
        39
        +      file 0: expenses:consulting

      
        
        40
        +      file 0: expenses:consulting

      
        
        41
        +    last-used: 2024-02-03

      
        13
        42
         

      
        14
        
        -payees (0):

      
        
        43
        +payees (1):

      
        
        44
        +  Client work

      
        
        45
        +    directives: 0

      
        
        46
        +    used: 2

      
        
        47
        +    usages: 2

      
        
        48
        +      file 0: Client work

      
        
        49
        +      file 0: Client work

      
        15
        50
         

      
        16
        
        -prefixes (0):

      
        
        51
        +prefixes (2):

      
        
        52
        +  assets:

      
        
        53
        +    assets:receivable

      
        
        54
        +  expenses:

      
        
        55
        +    expenses:consulting

      
        17
        56
         

      
        18
        
        -transactions: 0

      
        
        57
        +transactions: 2

      
        19
        58
         periodic transactions: 0

      
        20
        59
         automated transactions: 0

      
        21
        60
         

      ···
        24
        63
           tag personal

      
        25
        64
           tag project-xyz

      
        26
        65
         

      
        27
        
        -transaction keys (0):

      
        
        66
        +transaction keys (2):

      
        
        67
        +  2024-01-15|Client work|expenses:consulting,assets:receivable,

      
        
        68
        +  2024-02-03|Client work|expenses:consulting,assets:receivable,

      
        28
        69
         

      
        29
        
        -payee templates (0):

      
        
        70
        +payee templates (1):

      
        
        71
        +  Client work

      
        
        72
        +    expenses:consulting  $50

      
        
        73
        +    assets:receivable (inferred)

      
        30
        74
         

      
        31
        
        -dates (0):

      
        
        75
        +dates (2):

      
        
        76
        +  2024-01-15

      
        
        77
        +  2024-02-03

      
        32
        78
         

      
        33
        
        -tag names (2):

      
        
        79
        +tags (6):

      
        
        80
        +  billed

      
        
        81
        +    directives: 0

      
        
        82
        +    used: 2

      
        
        83
        +    usages: 2

      
        
        84
        +      file 0: billed=""

      
        
        85
        +      file 0: billed="extra"

      
        
        86
        +    values: extra

      
        
        87
        +    last-used: 2024-02-03

      
        
        88
        +  personal

      
        
        89
        +    directives: 1

      
        
        90
        +    used: 0

      
        
        91
        +      tag personal

      
        
        92
        +    usages: 0

      
        
        93
        +  project

      
        
        94
        +    directives: 0

      
        
        95
        +    used: 1

      
        
        96
        +    usages: 1

      
        
        97
        +      file 0: project="project-xyz"

      
        
        98
        +    values: project-xyz

      
        
        99
        +    last-used: 2024-01-15

      
        
        100
        +  project-xyz

      
        
        101
        +    directives: 2

      
        
        102
        +    used: 0

      
        
        103
        +      tag project-xyz

      
        
        104
        +      tag project-xyz

      
        
        105
        +    usages: 0

      
        
        106
        +  rate

      
        
        107
        +    directives: 0

      
        
        108
        +    used: 1

      
        
        109
        +    usages: 1

      
        
        110
        +      file 0: rate="200"

      
        
        111
        +    values: 200

      
        
        112
        +    last-used: 2024-01-15

      
        
        113
        +  reviewed

      
        
        114
        +    directives: 0

      
        
        115
        +    used: 1

      
        
        116
        +    usages: 1

      
        
        117
        +      file 0: reviewed="2024-02-01"

      
        
        118
        +    values: 2024-02-01

      
        
        119
        +

      
        
        120
        +tag names (6):

      
        
        121
        +  billed

      
        34
        122
           personal

      
        
        123
        +  project

      
        35
        124
           project-xyz

      
        
        125
        +  rate

      
        
        126
        +  reviewed

      
        36
        127
         

      
        37
        
        -commodity decimal marks (0):

      
        
        128
        +tag values (4):

      
        
        129
        +  200

      
        
        130
        +  2024-02-01

      
        
        131
        +  extra

      
        
        132
        +  project-xyz

      
        
        133
        +

      
        
        134
        +commodity decimal marks (1):

      
        
        135
        +  $: .

      
M internal/analyzer/testdata/typed-entries.txtar
···
        92
        92
         dates (1):

      
        93
        93
           2024-01-01

      
        94
        94
         

      
        
        95
        +tags (0):

      
        
        96
        +

      
        95
        97
         tag names (0):

      
        
        98
        +

      
        
        99
        +tag values (0):

      
        96
        100
         

      
        97
        101
         commodity decimal marks (1):

      
        98
        102
           $: .

      
M internal/lsp/testdata/completion-journal.txtar
···
        18
        18
         

      
        19
        19
         ; client:acme

      
        20
        20
         ; project:alpha

      
        
        21
        +; client:a^cme

      
        
        22
        +; project:^

      
        21
        23
         

      
        22
        24
         2024-01-16 ^

      
        23
        25
           expenses:f^ood  $5

      ···
        39
        41
         acc^ount

      
        40
        42
         

      
        41
        43
         -- expect --

      
        42
        
        -20:11 payee ""

      
        
        44
        +19:10 tag-value "a"

      
        
        45
        +  acme

      
        
        46
        +20:10 tag-value ""

      
        
        47
        +  alpha

      
        
        48
        +22:11 payee ""

      
        43
        49
           Grocery

      
        44
        50
           Store

      
        45
        
        -21:12 account "expenses:f"

      
        
        51
        +23:12 account "expenses:f"

      
        46
        52
           expenses:food

      
        47
        
        -26:16 account "assets:c"

      
        
        53
        +28:16 account "assets:c"

      
        48
        54
           assets:cash

      
        49
        
        -28:11 commodity "U"

      
        
        55
        +30:11 commodity "U"

      
        50
        56
           USD

      
        51
        
        -30:8 payee "Gr"

      
        
        57
        +32:8 payee "Gr"

      
        52
        58
           Grocery

      
        53
        
        -32:7 tag "cli"

      
        
        59
        +34:7 tag "cli"

      
        54
        60
           client

      
        55
        
        -34:5 tag "cli"

      
        
        61
        +36:5 tag "cli"

      
        56
        62
           client

      
        57
        
        -35:6 tag "proj"

      
        58
        
        -37:3 directive "acc"

      
        
        63
        +37:6 tag "proj"

      
        
        64
        +  project

      
        
        65
        +39:3 directive "acc"

      
        59
        66
           account

      
M internal/lsp/testdata/semantic-journal.txtar
···
        117
        117
           b  10 @@ 5 USD

      
        118
        118
           c  $-5.00 @ 0.9 BTC

      
        119
        119
         

      
        
        120
        +2024-01-22 Business dinner  ; client:acme, project:alpha

      
        
        121
        +    expenses:meals    $50  ; billable:

      
        
        122
        +    assets:cash            ; date:2024-01-16

      
        
        123
        +

      
        
        124
        +; tag:value, trailing text

      
        
        125
        +

      
        120
        126
         -- expect --

      
        121
        127
         0:0+12 comment

      
        122
        128
         1:0+2 comment

      ···
        389
        395
         116:12+1 operator

      
        390
        396
         116:14+3 number

      
        391
        397
         116:18+3 type

      
        
        398
        +118:0+10 class

      
        
        399
        +118:11+15 property

      
        
        400
        +118:28+2 comment

      
        
        401
        +118:30+11 property

      
        
        402
        +118:41+2 comment

      
        
        403
        +118:43+13 property

      
        
        404
        +119:4+14 namespace

      
        
        405
        +119:22+1 type

      
        
        406
        +119:23+2 number

      
        
        407
        +119:27+2 comment

      
        
        408
        +119:29+9 property

      
        
        409
        +120:4+11 namespace

      
        
        410
        +120:27+2 comment

      
        
        411
        +120:29+15 property

      
        
        412
        +122:0+2 comment

      
        
        413
        +122:2+9 property

      
        
        414
        +122:11+16 comment

      
M internal/lsp/textdocument_completion.go
···
        51
        51
         	cmplPayee

      
        52
        52
         	cmplCommodity

      
        53
        53
         	cmplTagName

      
        
        54
        +	cmplTagValue

      
        54
        55
         	cmplDirective

      
        55
        56
         )

      
        56
        57
         

      ···
        214
        215
         	return cmplNone, cursor

      
        215
        216
         }

      
        216
        217
         

      
        217
        
        -// cmplTagContext completes tag names at the start of a comment, stopping at first ':'.

      
        
        218
        +// commentStart completes tag names before ':' of the current tag and tag values after it

      
        218
        219
         func cmplTagContext(content string, commentStart, cursor int) (cmplCtx, int) {

      
        219
        
        -	if strings.ContainsRune(content[commentStart:cursor], ':') {

      
        220
        
        -		return cmplNone, cursor

      
        
        220
        +	prefix := content[commentStart:cursor]

      
        
        221
        +	segStart := commentStart

      
        
        222
        +	seg := prefix

      
        
        223
        +	if comma := strings.LastIndexByte(prefix, ','); comma >= 0 {

      
        
        224
        +		segStart = commentStart + comma + 1

      
        
        225
        +		seg = prefix[comma+1:]

      
        221
        226
         	}

      
        222
        
        -	keyStart := commentStart + lastSeparator(content[commentStart:cursor]) + 1

      
        
        227
        +	if colon := strings.IndexByte(seg, ':'); colon >= 0 {

      
        
        228
        +		start := segStart + colon + 1

      
        
        229
        +		for start < cursor && (content[start] == ' ' || content[start] == '\t') {

      
        
        230
        +			start++

      
        
        231
        +		}

      
        
        232
        +		return cmplTagValue, start

      
        
        233
        +	}

      
        
        234
        +	keyStart := commentStart + lastSeparator(prefix) + 1

      
        223
        235
         	return cmplTagName, keyStart

      
        
        236
        +}

      
        
        237
        +

      
        
        238
        +// tagKeyAt returns the key of tag whose value region starts at start

      
        
        239
        +func tagKeyAt(content string, start int) (string, bool) {

      
        
        240
        +	lineStart, _ := lineBounds(content, start)

      
        
        241
        +	segStart := lineStart

      
        
        242
        +	for i := start - 1; i >= lineStart; i-- {

      
        
        243
        +		switch content[i] {

      
        
        244
        +		case ',', ';', '#', '%':

      
        
        245
        +			segStart = i + 1

      
        
        246
        +			i = lineStart - 1 // stop at the separator closest to start

      
        
        247
        +		}

      
        
        248
        +	}

      
        
        249
        +	colon := strings.IndexByte(content[segStart:start], ':')

      
        
        250
        +	if colon < 0 {

      
        
        251
        +		return "", false

      
        
        252
        +	}

      
        
        253
        +	colon += segStart

      
        
        254
        +	keyStart := lastSeparator(content[segStart:colon]) + 1

      
        
        255
        +	key := content[segStart+keyStart : colon]

      
        
        256
        +	if key == "" {

      
        
        257
        +		return "", false

      
        
        258
        +	}

      
        
        259
        +	return key, true

      
        224
        260
         }

      
        225
        261
         

      
        226
        262
         // commentMarker returns index of the first comment marker token at or before the cursor, or -1.

      ···
        286
        322
         		}

      
        287
        323
         	case cmplTagName:

      
        288
        324
         		kind = protocol.CompletionItemKindProperty

      
        289
        
        -		for _, name := range a.TagNames {

      
        290
        
        -			cands = append(cands, cmplCand{label: name})

      
        
        325
        +		for name, info := range a.Tags {

      
        
        326
        +			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsed: info.LastUsed})

      
        
        327
        +		}

      
        
        328
        +	case cmplTagValue:

      
        
        329
        +		kind = protocol.CompletionItemKindProperty

      
        
        330
        +		if key, ok := tagKeyAt(content, start); ok {

      
        
        331
        +			if info, ok := a.Tags[key]; ok {

      
        
        332
        +				for _, v := range info.Values {

      
        
        333
        +					cands = append(cands, cmplCand{label: v})

      
        
        334
        +				}

      
        
        335
        +			}

      
        291
        336
         		}

      
        292
        337
         	case cmplDirective:

      
        293
        338
         		kind = protocol.CompletionItemKindKeyword

      
M internal/lsp/textdocument_completion_test.go
···
        16
        16
         )

      
        17
        17
         

      
        18
        18
         func TestDetectCompletionCtx(t *testing.T) {

      
        19
        
        -	tests := []struct {

      
        20
        
        -		name string

      
        21
        
        -		ctx  cmplCtx

      
        22
        
        -		in   string

      
        23
        
        -		want string

      
        
        19
        +	tests := map[string]struct {

      
        
        20
        +		ctx      cmplCtx

      
        
        21
        +		in, want string

      
        24
        22
         	}{

      
        25
        
        -		{"posting account", cmplAccount, "    expenses:f^ood  $50\n", "expenses:f"},

      
        26
        
        -		{"posting account empty", cmplAccount, "    ^\n", ""},

      
        27
        
        -		{"posting account after colon", cmplAccount, "    expenses:^food  $50\n", "expenses:"},

      
        28
        
        -		{"posting amount commodity", cmplCommodity, "    expenses:food  $^50\n", "$"},

      
        29
        
        -		{"posting empty amount region", cmplCommodity, "    expenses:food  ^\n", ""},

      
        30
        
        -		{"posting commodity word", cmplCommodity, "    expenses:food  U^SD\n", "U"},

      
        31
        
        -		{"posting amount number", cmplNone, "    expenses:food  $5^0\n", ""},

      
        32
        
        -		{"posting cost quantity", cmplNone, "    expenses:food  $50 @^ 1.5\n", ""},

      
        33
        
        -		{"posting status", cmplAccount, "    * expenses:f^ood  $50\n", "expenses:f"},

      
        34
        
        -		{"posting virtual", cmplAccount, "    (expenses:f^ood)  $50\n", "expenses:f"},

      
        35
        
        -		{"posting comment tag", cmplTagName, "    expenses:food  ; clie^nt:x\n", "clie"},

      
        36
        
        -		{"posting comment tag value", cmplNone, "    expenses:food  ; client:^x\n", ""},

      
        37
        
        -		{"header payee", cmplPayee, "2024-01-15 acm^e\n    assets:cash\n", "acm"},

      
        38
        
        -		{"header payee empty", cmplPayee, "2024-01-15 ^\n", ""},

      
        39
        
        -		{"header payee right after date", cmplPayee, "2024-01-15^\n", ""},

      
        40
        
        -		{"header status and code", cmplPayee, "2024-01-15 * (123) gro^cer\n", "gro"},

      
        41
        
        -		{"header second date", cmplPayee, "2024-01-15=2024-01-16 acm^e\n", "acm"},

      
        42
        
        -		{"header quoted payee", cmplPayee, "2024-01-15 \"ac^me\"\n", "ac"},

      
        43
        
        -		{"header pipe note", cmplNone, "2024-01-15 acme | no^te\n", ""},

      
        44
        
        -		{"header pipe inline", cmplNone, "2024-01-15 acme|note x^y\n", ""},

      
        45
        
        -		{"header inline comment", cmplTagName, "2024-01-15 ; foo^", "foo"},

      
        46
        
        -		{"directive keyword partial", cmplDirective, "acc^ount expenses\n", "acc"},

      
        47
        
        -		{"directive keyword empty line", cmplDirective, "\n^", ""},

      
        48
        
        -		{"account directive value", cmplAccount, "account exp^enses\n", "exp"},

      
        49
        
        -		{"commodity directive value", cmplCommodity, "commodity U^SD\n", "U"},

      
        50
        
        -		{"payee directive value", cmplPayee, "payee ac^me\n", "ac"},

      
        51
        
        -		{"tag directive value", cmplTagName, "tag pro^ject\n", "pro"},

      
        52
        
        -		{"comment tag", cmplTagName, "; clie^nt:x\n", "clie"},

      
        53
        
        -		{"comment value", cmplNone, "; client:x^yz\n", ""},

      
        54
        
        -		{"comment plain text", cmplTagName, "; groc^eries\n", "groc"},

      
        55
        
        -		{"subdirective ignored", cmplNone, "account expenses\n    no^te ignore\n", ""},

      
        56
        
        -		{"periodic header", cmplNone, "~ monthly^ budget\n", ""},

      
        57
        
        -		{"cjk posting", cmplAccount, "    支出:食^物  50\n", "支出:食"},

      
        58
        
        -		{"ukrainian payee", cmplPayee, "2024-01-15 прод^укти\n", "прод"},

      
        59
        
        -		{"crlf posting", cmplAccount, "2024-01-15 x\r\n    expenses:f^ood  $50\r\n", "expenses:f"},

      
        60
        
        -		{"crlf header", cmplPayee, "2024-01-15 acm^e\r\n    assets:cash\r\n", "acm"},

      
        
        23
        +		"posting account":                  {cmplAccount, "    expenses:f^ood  $50\n", "expenses:f"},

      
        
        24
        +		"posting account empty":            {cmplAccount, "    ^\n", ""},

      
        
        25
        +		"posting account after colon":      {cmplAccount, "    expenses:^food  $50\n", "expenses:"},

      
        
        26
        +		"posting amount commodity":         {cmplCommodity, "    expenses:food  $^50\n", "$"},

      
        
        27
        +		"posting empty amount region":      {cmplCommodity, "    expenses:food  ^\n", ""},

      
        
        28
        +		"posting commodity word":           {cmplCommodity, "    expenses:food  U^SD\n", "U"},

      
        
        29
        +		"posting amount number":            {cmplNone, "    expenses:food  $5^0\n", ""},

      
        
        30
        +		"posting cost quantity":            {cmplNone, "    expenses:food  $50 @^ 1.5\n", ""},

      
        
        31
        +		"posting status":                   {cmplAccount, "    * expenses:f^ood  $50\n", "expenses:f"},

      
        
        32
        +		"posting virtual":                  {cmplAccount, "    (expenses:f^ood)  $50\n", "expenses:f"},

      
        
        33
        +		"posting comment tag":              {cmplTagName, "    expenses:food  ; clie^nt:x\n", "clie"},

      
        
        34
        +		"posting comment tag value":        {cmplTagValue, "    expenses:food  ; client:^x\n", ""},

      
        
        35
        +		"header payee":                     {cmplPayee, "2024-01-15 acm^e\n    assets:cash\n", "acm"},

      
        
        36
        +		"header payee empty":               {cmplPayee, "2024-01-15 ^\n", ""},

      
        
        37
        +		"header payee right after date":    {cmplPayee, "2024-01-15^\n", ""},

      
        
        38
        +		"header status and code":           {cmplPayee, "2024-01-15 * (123) gro^cer\n", "gro"},

      
        
        39
        +		"header second date":               {cmplPayee, "2024-01-15=2024-01-16 acm^e\n", "acm"},

      
        
        40
        +		"header quoted payee":              {cmplPayee, "2024-01-15 \"ac^me\"\n", "ac"},

      
        
        41
        +		"header pipe note":                 {cmplNone, "2024-01-15 acme | no^te\n", ""},

      
        
        42
        +		"header pipe inline":               {cmplNone, "2024-01-15 acme|note x^y\n", ""},

      
        
        43
        +		"header inline comment":            {cmplTagName, "2024-01-15 ; foo^", "foo"},

      
        
        44
        +		"directive keyword partial":        {cmplDirective, "acc^ount expenses\n", "acc"},

      
        
        45
        +		"directive keyword empty line":     {cmplDirective, "\n^", ""},

      
        
        46
        +		"account directive value":          {cmplAccount, "account exp^enses\n", "exp"},

      
        
        47
        +		"commodity directive value":        {cmplCommodity, "commodity U^SD\n", "U"},

      
        
        48
        +		"payee directive value":            {cmplPayee, "payee ac^me\n", "ac"},

      
        
        49
        +		"tag directive value":              {cmplTagName, "tag pro^ject\n", "pro"},

      
        
        50
        +		"comment tag":                      {cmplTagName, "; clie^nt:x\n", "clie"},

      
        
        51
        +		"comment value":                    {cmplTagValue, "; client:x^yz\n", "x"},

      
        
        52
        +		"comment value empty":              {cmplTagValue, "; client:^\n", ""},

      
        
        53
        +		"comment value after space":        {cmplTagValue, "; client: ac^me\n", "ac"},

      
        
        54
        +		"comment value second tag":         {cmplTagValue, "; a:b, client:x^\n", "x"},

      
        
        55
        +		"comment value with colon":         {cmplTagValue, "; url:https://example^\n", "https://example"},

      
        
        56
        +		"comment key after previous value": {cmplTagName, "; a:b:c, cli^ent:x\n", "cli"},

      
        
        57
        +		"comment plain text":               {cmplTagName, "; groc^eries\n", "groc"},

      
        
        58
        +		"subdirective ignored":             {cmplNone, "account expenses\n    no^te ignore\n", ""},

      
        
        59
        +		"periodic header":                  {cmplNone, "~ monthly^ budget\n", ""},

      
        
        60
        +		"cjk posting":                      {cmplAccount, "    支出:食^物  50\n", "支出:食"},

      
        
        61
        +		"ukrainian payee":                  {cmplPayee, "2024-01-15 прод^укти\n", "прод"},

      
        
        62
        +		"crlf posting":                     {cmplAccount, "2024-01-15 x\r\n    expenses:f^ood  $50\r\n", "expenses:f"},

      
        
        63
        +		"crlf header":                      {cmplPayee, "2024-01-15 acm^e\r\n    assets:cash\r\n", "acm"},

      
        61
        64
         	}

      
        62
        
        -	for _, tt := range tests {

      
        63
        
        -		t.Run(tt.name, func(t *testing.T) {

      
        
        65
        +	for tname, tt := range tests {

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

      
        64
        67
         			cont := tt.in

      
        65
        68
         			i := strings.Index(cont, "^")

      
        66
        69
         			if i < 0 {

      ···
        93
        96
         		return "commodity"

      
        94
        97
         	case cmplTagName:

      
        95
        98
         		return "tag"

      
        
        99
        +	case cmplTagValue:

      
        
        100
        +		return "tag-value"

      
        96
        101
         	case cmplDirective:

      
        97
        102
         		return "directive"

      
        98
        103
         	default:

      
M internal/lsp/textdocument_semantic.go
···
        192
        192
         	return 1

      
        193
        193
         }

      
        194
        194
         

      
        195
        
        -func visitEntry(content string, e ast.Entry, emit semEmitFn) {

      
        
        195
        +func visitEntry(content string, e ast.Entry, emit semEmitFunc) {

      
        196
        196
         	switch e := e.(type) {

      
        197
        197
         	case *ast.Transaction:

      
        198
        198
         		visitTransaction(content, e, emit)

      ···
        203
        203
         	case *ast.AccountDirective:

      
        204
        204
         		emit(directiveKeyword(e.Span, "account"), semDirective, 0)

      
        205
        205
         		emit(e.Account.Span, semAccount, 0)

      
        206
        
        -		if e.Comment != nil {

      
        207
        
        -			emit(e.Comment.Span, semComment, 0)

      
        208
        
        -		}

      
        
        206
        +		emitComment(e.Comment, emit)

      
        209
        207
         	case *ast.CommodityDirective:

      
        210
        208
         		emit(directiveKeyword(e.Span, "commodity"), semDirective, 0)

      
        211
        209
         		if e.Format.Span.End.Offset > 0 {

      ···
        213
        211
         		} else if e.CommoditySpan.Start.Offset > 0 && e.CommoditySpan.End.Offset > 0 {

      
        214
        212
         			emit(e.CommoditySpan, semCommodity, 0)

      
        215
        213
         		}

      
        216
        
        -		if e.Comment != nil {

      
        217
        
        -			emit(e.Comment.Span, semComment, 0)

      
        218
        
        -		}

      
        
        214
        +		emitComment(e.Comment, emit)

      
        219
        215
         	case *ast.IncludeDirective:

      
        220
        216
         		emitDirective(content, e.Span, len("include"), semString, e.Comment, emit)

      
        221
        217
         	case *ast.PayeeDirective:

      ···
        229
        225
         			emit(op, semOperator, 0)

      
        230
        226
         		}

      
        231
        227
         		emit(e.To.Span, semAccount, 0)

      
        232
        
        -		if e.Comment != nil {

      
        233
        
        -			emit(e.Comment.Span, semComment, 0)

      
        234
        
        -		}

      
        
        228
        +		emitComment(e.Comment, emit)

      
        235
        229
         	case *ast.YearDirective:

      
        236
        230
         		kwLen := len("year")

      
        237
        231
         		if content[e.Span.Start.Offset] == 'Y' {

      ···
        243
        237
         	case *ast.DefaultCommodityDirective:

      
        244
        238
         		emit(directiveKeyword(e.Span, "D"), semDirective, 0)

      
        245
        239
         		semEmitAmount(content, &e.Amount, emit)

      
        246
        
        -		if e.Comment != nil {

      
        247
        
        -			emit(e.Comment.Span, semComment, 0)

      
        248
        
        -		}

      
        
        240
        +		emitComment(e.Comment, emit)

      
        249
        241
         	case *ast.MarketPriceDirective:

      
        250
        242
         		emit(directiveKeyword(e.Span, "P"), semDirective, 0)

      
        251
        243
         		emit(e.DateTime.Date.Span, semDate, 0)

      ···
        261
        253
         			emit(comm, semCommodity, 0)

      
        262
        254
         		}

      
        263
        255
         		semEmitAmount(content, &e.Amount, emit)

      
        264
        
        -		if e.Comment != nil {

      
        265
        
        -			emit(e.Comment.Span, semComment, 0)

      
        266
        
        -		}

      
        
        256
        +		emitComment(e.Comment, emit)

      
        267
        257
         	case *ast.ConversionDirective:

      
        268
        258
         		emit(directiveKeyword(e.Span, "C"), semDirective, 0)

      
        269
        259
         		semEmitAmount(content, &e.From, emit)

      ···
        272
        262
         			emit(op, semOperator, 0)

      
        273
        263
         		}

      
        274
        264
         		semEmitAmount(content, &e.To, emit)

      
        275
        
        -		if e.Comment != nil {

      
        276
        
        -			emit(e.Comment.Span, semComment, 0)

      
        277
        
        -		}

      
        
        265
        +		emitComment(e.Comment, emit)

      
        278
        266
         	case *ast.Comment:

      
        279
        
        -		emit(e.Span, semComment, 0)

      
        
        267
        +		emitComment(e, emit)

      
        280
        268
         	case *ast.CommentBlockDirective:

      
        281
        269
         		emit(e.Span, semComment, 0)

      
        282
        270
         	case *ast.IgnoredDirective:

      ···
        289
        277
         	}

      
        290
        278
         }

      
        291
        279
         

      
        292
        
        -func visitTransaction(content string, t *ast.Transaction, emit semEmitFn) {

      
        
        280
        +func visitTransaction(content string, t *ast.Transaction, emit semEmitFunc) {

      
        293
        281
         	emit(t.Date.Span, semDate, 0)

      
        294
        282
         	if t.SecondDate != nil {

      
        295
        283
         		emit(t.SecondDate.Span, semDate, 0)

      ···
        306
        294
         	if t.Note != nil {

      
        307
        295
         		emit(t.Note.Span, semProperty, 0)

      
        308
        296
         	}

      
        309
        
        -	if t.Comment != nil {

      
        310
        
        -		emit(t.Comment.Span, semComment, 0)

      
        311
        
        -	}

      
        
        297
        +	emitComment(t.Comment, emit)

      
        312
        298
         	for i := range t.HeaderComments {

      
        313
        
        -		emit(t.HeaderComments[i].Span, semComment, 0)

      
        
        299
        +		emitComment(t.HeaderComments[i], emit)

      
        314
        300
         	}

      
        315
        301
         	for _, p := range t.Postings {

      
        316
        302
         		visitPosting(content, p, emit)

      
        317
        303
         	}

      
        318
        304
         }

      
        319
        305
         

      
        320
        
        -func visitPeriodicTransaction(content string, pt *ast.PeriodicTransaction, emit semEmitFn) {

      
        
        306
        +func visitPeriodicTransaction(content string, pt *ast.PeriodicTransaction, emit semEmitFunc) {

      
        321
        307
         	// ~ operator is at the start of the period span

      
        322
        308
         	emit(offsetSpan(pt.Span.Start.File, pt.Span.Start.Offset, pt.Span.Start.Offset+1), semOperator, 0)

      
        323
        309
         

      ···
        345
        331
         	if pt.Description != nil {

      
        346
        332
         		emit(pt.Description.Span, semProperty, 0)

      
        347
        333
         	}

      
        348
        
        -	if pt.Comment != nil {

      
        349
        
        -		emit(pt.Comment.Span, semComment, 0)

      
        350
        
        -	}

      
        
        334
        +	emitComment(pt.Comment, emit)

      
        351
        335
         	for i := range pt.HeaderComments {

      
        352
        
        -		emit(pt.HeaderComments[i].Span, semComment, 0)

      
        
        336
        +		emitComment(pt.HeaderComments[i], emit)

      
        353
        337
         	}

      
        354
        338
         	for _, p := range pt.Postings {

      
        355
        339
         		visitPosting(content, p, emit)

      
        356
        340
         	}

      
        357
        341
         }

      
        358
        342
         

      
        359
        
        -func visitAutomatedTransaction(content string, at *ast.AutomatedTransaction, emit semEmitFn) {

      
        
        343
        +func visitAutomatedTransaction(content string, at *ast.AutomatedTransaction, emit semEmitFunc) {

      
        360
        344
         	// = operator is at the start of the expression span

      
        361
        345
         	emit(offsetSpan(at.Span.Start.File, at.Span.Start.Offset, at.Span.Start.Offset+1), semOperator, 0)

      
        362
        346
         

      
        363
        347
         	if at.Expr.Value != "" {

      
        364
        348
         		emit(at.Expr.Span, semString, 0)

      
        365
        349
         	}

      
        366
        
        -	if at.Comment != nil {

      
        367
        
        -		emit(at.Comment.Span, semComment, 0)

      
        368
        
        -	}

      
        
        350
        +	emitComment(at.Comment, emit)

      
        369
        351
         	for i := range at.HeaderComments {

      
        370
        
        -		emit(at.HeaderComments[i].Span, semComment, 0)

      
        
        352
        +		emitComment(at.HeaderComments[i], emit)

      
        371
        353
         	}

      
        372
        354
         	for _, p := range at.Postings {

      
        373
        355
         		visitPosting(content, p, emit)

      
        374
        356
         	}

      
        375
        357
         }

      
        376
        358
         

      
        377
        
        -func visitPosting(content string, p *ast.Posting, emit semEmitFn) {

      
        
        359
        +func visitPosting(content string, p *ast.Posting, emit semEmitFunc) {

      
        378
        360
         	if p.Status.Value != ast.StatusNone {

      
        379
        361
         		emit(p.Status.Span, semStatus, 0)

      
        380
        362
         	}

      ···
        410
        392
         	if p.Balance != nil {

      
        411
        393
         		semEmitBalanceAssertion(content, p.Balance, emit)

      
        412
        394
         	}

      
        413
        
        -	if p.Comment != nil {

      
        414
        
        -		emit(p.Comment.Span, semComment, 0)

      
        415
        
        -	}

      
        
        395
        +	emitComment(p.Comment, emit)

      
        416
        396
         	for i := range p.Comments {

      
        417
        
        -		emit(p.Comments[i].Span, semComment, 0)

      
        
        397
        +		emitComment(&p.Comments[i], emit)

      
        418
        398
         	}

      
        419
        399
         }

      
        420
        400
         

      ···
        433
        413
         	return betweenSpan(content, e.Start.File, kwEnd, end)

      
        434
        414
         }

      
        435
        415
         

      
        436
        
        -func semEmitAmount(content string, a *ast.Amount, emit semEmitFn) {

      
        
        416
        +func semEmitAmount(content string, a *ast.Amount, emit semEmitFunc) {

      
        437
        417
         	if a == nil {

      
        438
        418
         		return

      
        439
        419
         	}

      ···
        449
        429
         	}

      
        450
        430
         }

      
        451
        431
         

      
        452
        
        -func semEmitQuantity(content string, a *ast.Amount, emit semEmitFn) {

      
        
        432
        +func semEmitQuantity(content string, a *ast.Amount, emit semEmitFunc) {

      
        453
        433
         	qStart, qEnd := quantitySpan(content, a)

      
        454
        434
         	if qEnd <= qStart {

      
        455
        435
         		return

      ···
        482
        462
         	return start, end

      
        483
        463
         }

      
        484
        464
         

      
        485
        
        -type semEmitFn func(token.Span, uint32, uint32)

      
        
        465
        +type semEmitFunc func(tok token.Span, tokKind, modifier uint32)

      
        
        466
        +

      
        
        467
        +func emitComment(c *ast.Comment, emit semEmitFunc) {

      
        
        468
        +	if c == nil {

      
        
        469
        +		return

      
        
        470
        +	}

      
        
        471
        +	if len(c.Tags) == 0 {

      
        
        472
        +		emit(c.Span, semComment, 0)

      
        
        473
        +		return

      
        
        474
        +	}

      
        
        475
        +	pos := c.Span.Start.Offset

      
        
        476
        +	for _, t := range c.Tags {

      
        
        477
        +		if t.Span.Start.Offset > pos {

      
        
        478
        +			emit(offsetSpan(c.Span.Start.File, pos, t.Span.Start.Offset), semComment, 0)

      
        
        479
        +		}

      
        
        480
        +		emit(t.Span, semProperty, 0)

      
        
        481
        +		pos = t.Span.End.Offset

      
        
        482
        +	}

      
        
        483
        +	if pos < c.Span.End.Offset {

      
        
        484
        +		emit(offsetSpan(c.Span.Start.File, pos, c.Span.End.Offset), semComment, 0)

      
        
        485
        +	}

      
        
        486
        +}

      
        486
        487
         

      
        487
        
        -func emitDirective(content string, e token.Span, kwLen int, valType uint32, comment *ast.Comment, emit semEmitFn) {

      
        
        488
        +func emitDirective(content string, e token.Span, kwLen int, valType uint32, comment *ast.Comment, emit semEmitFunc) {

      
        488
        489
         	kwEnd := e.Start.Offset + kwLen

      
        489
        490
         	emit(token.Span{Start: e.Start, End: offsetPos(e.Start.File, kwEnd)}, semDirective, 0)

      
        490
        491
         	if v, ok := directiveValue(content, e, comment, kwEnd); ok {

      
        491
        492
         		emit(v, valType, 0)

      
        492
        493
         	}

      
        493
        
        -	if comment != nil {

      
        494
        
        -		emit(comment.Span, semComment, 0)

      
        495
        
        -	}

      
        
        494
        +	emitComment(comment, emit)

      
        496
        495
         }

      
        497
        496
         

      
        498
        
        -func semEmitCost(content string, c *ast.Cost, emit semEmitFn) {

      
        
        497
        +func semEmitCost(content string, c *ast.Cost, emit semEmitFunc) {

      
        499
        498
         	if c.IsTotal {

      
        500
        499
         		emit(token.Span{Start: c.Span.Start, End: offsetPos(c.Span.Start.File, c.Span.Start.Offset+2)}, semOperator, 0)

      
        501
        500
         	} else {

      ···
        504
        503
         	semEmitAmount(content, &c.Amount, emit)

      
        505
        504
         }

      
        506
        505
         

      
        507
        
        -func semEmitBalanceAssertion(content string, ba *ast.BalanceAssertion, emit semEmitFn) {

      
        
        506
        +func semEmitBalanceAssertion(content string, ba *ast.BalanceAssertion, emit semEmitFunc) {

      
        508
        507
         	// The operator is the run of '=', ':', '*' chars from the span start.

      
        509
        508
         	// The ':' of ':=' precedes the '=' token, so back up one offset.

      
        510
        509
         	opStart := ba.Span.Start.Offset

      ···
        522
        521
         	}

      
        523
        522
         }

      
        524
        523
         

      
        525
        
        -func semLexerFallback(content string, base []rawSpan, emit semEmitFn) {

      
        
        524
        +func semLexerFallback(content string, base []rawSpan, emit semEmitFunc) {

      
        526
        525
         	l := lexer.New("", []byte(content))

      
        527
        526
         

      
        528
        527
         	var commentStart, commentEnd int // 0 = not inside a comment line

      
M journal/ast/ast.go
···
        66
        66
         	Span token.Span

      
        67
        67
         }

      
        68
        68
         

      
        
        69
        +type Tag struct {

      
        
        70
        +	Key, Value string

      
        
        71
        +	Span       token.Span

      
        
        72
        +}

      
        
        73
        +

      
        69
        74
         type Comment struct {

      
        70
        75
         	Marker byte // ';' '#' '%' '*'

      
        
        76
        +	Tags   []Tag

      
        71
        77
         	Text   string

      
        72
        78
         	Span   token.Span

      
        73
        79
         }

      
M journal/ast/dump.go
···
        361
        361
         	fmt.Fprintf(b, "Comment %s\n", c.Span)

      
        362
        362
         	indent(b, depth+1)

      
        363
        363
         	fmt.Fprintf(b, "Marker: %q\n", string(c.Marker))

      
        
        364
        +	if len(c.Tags) > 0 {

      
        
        365
        +		indent(b, depth+1)

      
        
        366
        +		fmt.Fprintf(b, "Tags\n")

      
        
        367
        +		for _, t := range c.Tags {

      
        
        368
        +			indent(b, depth+2)

      
        
        369
        +			fmt.Fprintf(b, "%q=%q  %s\n", t.Key, t.Value, t.Span)

      
        
        370
        +		}

      
        
        371
        +	}

      
        364
        372
         	indent(b, depth+1)

      
        365
        373
         	fmt.Fprintf(b, "Text: %q\n", c.Text)

      
        366
        374
         }

      
M journal/parser/parser.go
···
        4
        4
         	"fmt"

      
        5
        5
         	"strconv"

      
        6
        6
         	"strings"

      
        
        7
        +	"unicode"

      
        
        8
        +	"unicode/utf8"

      
        7
        9
         

      
        8
        10
         	"olexsmir.xyz/clerk/internal/decimal"

      
        9
        11
         	"olexsmir.xyz/clerk/journal/ast"

      ···
        1072
        1074
         	p.advance()

      
        1073
        1075
         	p.skipWhitespace()

      
        1074
        1076
         

      
        
        1077
        +	var tags []ast.Tag

      
        1075
        1078
         	text := ""

      
        1076
        1079
         	if p.got(token.TEXT) {

      
        1077
        1080
         		text = p.cur.Literal

      
        
        1081
        +		tags = parseCommentTags(text, p.cur.Span.Start)

      
        1078
        1082
         		p.advance()

      
        1079
        1083
         	}

      
        1080
        1084
         

      
        1081
        1085
         	return &ast.Comment{

      
        1082
        1086
         		Marker: marker,

      
        
        1087
        +		Tags:   tags,

      
        1083
        1088
         		Text:   text,

      
        1084
        1089
         		Span:   p.span(s),

      
        1085
        1090
         	}

      ···
        1333
        1338
         	}

      
        1334
        1339
         	return 0

      
        1335
        1340
         }

      
        
        1341
        +

      
        
        1342
        +// parseCommentTags extacts tags from comment text.

      
        
        1343
        +// A tag is a word immediately followed by a ':', with an optional value that ends at a comma or the end of a line.

      
        
        1344
        +// https://hledger.org/1.52/hledger.html?highlight=tags#tags

      
        
        1345
        +func parseCommentTags(text string, base token.Pos) []ast.Tag {

      
        
        1346
        +	var tags []ast.Tag

      
        
        1347
        +	for i := 0; i < len(text); {

      
        
        1348
        +		colon := strings.IndexByte(text[i:], ':')

      
        
        1349
        +		if colon < 0 {

      
        
        1350
        +			break

      
        
        1351
        +		}

      
        
        1352
        +		colon += i

      
        
        1353
        +

      
        
        1354
        +		keyStart := colon

      
        
        1355
        +		for keyStart > i {

      
        
        1356
        +			r, size := utf8.DecodeLastRuneInString(text[:keyStart])

      
        
        1357
        +			if unicode.IsSpace(r) {

      
        
        1358
        +				break

      
        
        1359
        +			}

      
        
        1360
        +			keyStart -= size

      
        
        1361
        +		}

      
        
        1362
        +		if keyStart == colon { // nothing before the colon = not a tag

      
        
        1363
        +			i = colon + 1

      
        
        1364
        +			continue

      
        
        1365
        +		}

      
        
        1366
        +		key := text[keyStart:colon]

      
        
        1367
        +

      
        
        1368
        +		valueEnd := colon + 1

      
        
        1369
        +		for valueEnd < len(text) && text[valueEnd] != ',' {

      
        
        1370
        +			valueEnd++

      
        
        1371
        +		}

      
        
        1372
        +		value := strings.TrimSpace(text[colon+1 : valueEnd])

      
        
        1373
        +

      
        
        1374
        +		tags = append(tags, ast.Tag{

      
        
        1375
        +			Key:   key,

      
        
        1376
        +			Value: value,

      
        
        1377
        +			Span: token.Span{

      
        
        1378
        +				Start: tagPos(base, text, keyStart),

      
        
        1379
        +				End:   tagPos(base, text, valueEnd),

      
        
        1380
        +			},

      
        
        1381
        +		})

      
        
        1382
        +		i = valueEnd

      
        
        1383
        +		if i < len(text) && text[i] == ',' {

      
        
        1384
        +			i++

      
        
        1385
        +		}

      
        
        1386
        +	}

      
        
        1387
        +

      
        
        1388
        +	return tags

      
        
        1389
        +}

      
        
        1390
        +

      
        
        1391
        +func tagPos(base token.Pos, text string, off int) token.Pos {

      
        
        1392
        +	return token.Pos{

      
        
        1393
        +		File:   base.File,

      
        
        1394
        +		Offset: base.Offset + off,

      
        
        1395
        +		Line:   base.Line,

      
        
        1396
        +		Col:    base.Col + utf8.RuneCountInString(text[:off]),

      
        
        1397
        +	}

      
        
        1398
        +}

      
M journal/parser/parser_test.go
···
        15
        15
         		"comment hash",

      
        16
        16
         		"comment percent",

      
        17
        17
         		"comment star",

      
        
        18
        +		"comment with tags",

      
        
        19
        +		"transaction with tag comments",

      
        18
        20
         		"alias directive",

      
        19
        21
         		"tag directive",

      
        20
        22
         		"year directive",

      ···
        130
        132
         	f.Add([]byte("~ monthly\n    expenses:food  $100\n    assets:checking\n"))

      
        131
        133
         	f.Add([]byte("= /^Income/\n  expenses:food  $10\n"))

      
        132
        134
         	f.Add([]byte("; a comment\n"))

      
        
        135
        +	f.Add([]byte("; tag1:val1, tag2:val2\n"))

      
        133
        136
         	f.Add([]byte("comment\nbody\nend\n"))

      
        134
        137
         	f.Add([]byte("\n\n\n"))

      
        135
        138
         	f.Add([]byte("перевірка\n"))

      
A journal/parser/testdata/comment_with_tags.txtar
···
        
        1
        +-- input --

      
        
        2
        +; client:acme, project:alpha

      
        
        3
        +; reviewed:

      
        
        4
        +; url:https://example.com, date:2024-01-16

      
        
        5
        +# client:acme

      
        
        6
        +

      
        
        7
        +; receipt from shop, paid with card

      
        
        8
        +; see docs: x

      
        
        9
        +; :foo bar:baz

      
        
        10
        +; foo :bar

      
        
        11
        +

      
        
        12
        +-- expect --

      
        
        13
        +Journal

      
        
        14
        +  Comment j:1:1-2:1

      
        
        15
        +    Marker: ";"

      
        
        16
        +    Tags

      
        
        17
        +      "client"="acme"  j:1:3-1:14

      
        
        18
        +      "project"="alpha"  j:1:16-1:29

      
        
        19
        +    Text: "client:acme, project:alpha"

      
        
        20
        +  Comment j:2:1-3:1

      
        
        21
        +    Marker: ";"

      
        
        22
        +    Tags

      
        
        23
        +      "reviewed"=""  j:2:3-2:12

      
        
        24
        +    Text: "reviewed:"

      
        
        25
        +  Comment j:3:1-4:1

      
        
        26
        +    Marker: ";"

      
        
        27
        +    Tags

      
        
        28
        +      "url"="https://example.com"  j:3:3-3:26

      
        
        29
        +      "date"="2024-01-16"  j:3:28-3:43

      
        
        30
        +    Text: "url:https://example.com, date:2024-01-16"

      
        
        31
        +  Comment j:4:1-6:0

      
        
        32
        +    Marker: "#"

      
        
        33
        +    Tags

      
        
        34
        +      "client"="acme"  j:4:3-4:14

      
        
        35
        +    Text: "client:acme"

      
        
        36
        +  BlankLine j:6:0-6:1

      
        
        37
        +  Comment j:6:1-7:1

      
        
        38
        +    Marker: ";"

      
        
        39
        +    Text: "receipt from shop, paid with card"

      
        
        40
        +  Comment j:7:1-8:1

      
        
        41
        +    Marker: ";"

      
        
        42
        +    Tags

      
        
        43
        +      "docs"="x"  j:7:7-7:14

      
        
        44
        +    Text: "see docs: x"

      
        
        45
        +  Comment j:8:1-9:1

      
        
        46
        +    Marker: ";"

      
        
        47
        +    Tags

      
        
        48
        +      "bar"="baz"  j:8:8-8:15

      
        
        49
        +    Text: ":foo bar:baz"

      
        
        50
        +  Comment j:9:1-11:0

      
        
        51
        +    Marker: ";"

      
        
        52
        +    Text: "foo :bar"

      
        
        53
        +  BlankLine j:11:0-11:1

      
A journal/parser/testdata/transaction_with_tag_comments.txtar
···
        
        1
        +-- input --

      
        
        2
        +2024-01-15 Business dinner  ; client:acme, project:alpha

      
        
        3
        +    expenses:meals    $50  ; billable:

      
        
        4
        +    assets:cash            ; date:2024-01-16

      
        
        5
        +

      
        
        6
        +-- expect --

      
        
        7
        +Journal

      
        
        8
        +  Transaction j:1:1-5:0

      
        
        9
        +    Date: 2024-01-15

      
        
        10
        +    Payee: "Business dinner" j:1:12-1:27

      
        
        11
        +    Comment j:1:29-2:0

      
        
        12
        +      Marker: ";"

      
        
        13
        +      Tags

      
        
        14
        +        "client"="acme"  j:1:31-1:42

      
        
        15
        +        "project"="alpha"  j:1:44-1:57

      
        
        16
        +      Text: "client:acme, project:alpha"

      
        
        17
        +    Posting j:2:1-3:1

      
        
        18
        +      Account j:2:5-2:19

      
        
        19
        +        SubAccount: "expenses" j:2:5-2:13

      
        
        20
        +        SubAccount: "meals" j:2:14-2:19

      
        
        21
        +      Amount j:2:23-2:26

      
        
        22
        +        Quantity: 50

      
        
        23
        +        Commodity: "$" j:2:23-2:24

      
        
        24
        +        CommodityPos: Before

      
        
        25
        +        HasSpace: false

      
        
        26
        +        Precision: 0

      
        
        27
        +        Decimal: "."

      
        
        28
        +      Comment j:2:28-3:0

      
        
        29
        +        Marker: ";"

      
        
        30
        +        Tags

      
        
        31
        +          "billable"=""  j:2:30-2:39

      
        
        32
        +        Text: "billable:"

      
        
        33
        +    Posting j:3:1-5:0

      
        
        34
        +      Account j:3:5-3:16

      
        
        35
        +        SubAccount: "assets" j:3:5-3:11

      
        
        36
        +        SubAccount: "cash" j:3:12-3:16

      
        
        37
        +      Amount: <elided>

      
        
        38
        +      Comment j:3:28-4:0

      
        
        39
        +        Marker: ";"

      
        
        40
        +        Tags

      
        
        41
        +          "date"="2024-01-16"  j:3:30-3:45

      
        
        42
        +        Text: "date:2024-01-16"

      
        
        43
        +  BlankLine j:5:0-5:1