all repos

clerk @ 4b5c1daa462d27e6b3657a9beefa95418d460b02

missing tooling for ledger/hledger
5 files changed, 601 insertions(+), 0 deletions(-)
lsp: hover
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-08-13 13:40:52 +0300
Authored at: 2026-08-13 11:36:32 +0300
Change ID: xmtzvytrtvzqqnmvlnqonvvrwlynzmnw
Parent: f9e2eff
M internal/lsp/server.go
···
        72
        72
         		Capabilities: protocol.ServerCapabilities{

      
        73
        73
         			DocumentFormattingProvider: &protocol.DocumentFormattingOptions{},

      
        74
        74
         			DefinitionProvider:         protocol.Boolean(true),

      
        
        75
        +			HoverProvider:              protocol.Boolean(true),

      
        75
        76
         			RenameProvider: &protocol.RenameOptions{

      
        76
        77
         				PrepareProvider: new(true),

      
        77
        78
         			},

      
A internal/lsp/testdata/hover-include.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +include sub.journal

      
        
        3
        +

      
        
        4
        +2024-01-16 Another Store

      
        
        5
        +    expenses:food  100.00 E^UR

      
        
        6
        +    assets:ca^sh

      
        
        7
        +

      
        
        8
        +-- sub.journal --

      
        
        9
        +account expenses:food

      
        
        10
        +

      
        
        11
        +2024-01-15 Gro^cery Store

      
        
        12
        +    expenses:fo^od  $50

      
        
        13
        +    assets:cash

      
        
        14
        +

      
        
        15
        +-- expect --

      
        
        16
        +3:27 "**Amount:** 100.00 EUR" 3:19-3:29

      
        
        17
        +4:13 "**Account:** `assets:cash`\n**Postings:** 2\n**Last used:** 2024-01-16" 4:4-4:15

      
A internal/lsp/testdata/hover-journal.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +account expenses:fo^od

      
        
        3
        +account assets:cash

      
        
        4
        +  alias check^ing

      
        
        5
        +alias fo^od = expenses:food

      
        
        6
        +alias food = expenses:fo^od

      
        
        7
        +commodity E^UR

      
        
        8
        +payee Gro^cery Store

      
        
        9
        +tag cli^ent

      
        
        10
        +

      
        
        11
        +^2024-01-15 Gro^cery Store ; proj^ect:ho^me

      
        
        12
        +    expenses:food  ^$50^.00 @ ^$1.5

      
        
        13
        +    assets:checking  $5^0.00 @ $1.5

      
        
        14
        +    assets:cash  = ^$30

      
        
        15
        +    ; cl^ient:acme

      
        
        16
        +    ; completed:^

      
        
        17
        +

      
        
        18
        +2024-01-15 Market

      
        
        19
        +    expenses:food  $5

      
        
        20
        +    assets:cash  $-5

      
        
        21
        +

      
        
        22
        +2024-01-16 Grocery Store ; project:wo^rk

      
        
        23
        +    expenses:food  1.000,00 E^UR

      
        
        24
        +    assets:cash  -1.000,0^0 EUR

      
        
        25
        +

      
        
        26
        +2024-01-17 прод^укти

      
        
        27
        +    expenses:fo^od  $10

      
        
        28
        +    assets:ca^sh

      
        
        29
        +

      
        
        30
        +^

      
        
        31
        +~ monthly

      
        
        32
        +    expenses:fo^od  $1

      
        
        33
        +    assets:cash

      
        
        34
        +

      
        
        35
        += expenses:food > 100

      
        
        36
        +    assets:ca^sh  $-1

      
        
        37
        +

      
        
        38
        +-- expect --

      
        
        39
        +0:19 "**Account:** `expenses:food`\n**Postings:** 5\n**Last used:** 2024-01-17" 0:8-0:21

      
        
        40
        +2:13 "**Account:** `assets:cash`\n**Postings:** 6\n**Last used:** 2024-01-17" 2:8-2:16

      
        
        41
        +3:8 "**Account:** `expenses:food`\n**Postings:** 5\n**Last used:** 2024-01-17" 3:6-3:10

      
        
        42
        +4:24 "**Account:** `expenses:food`\n**Postings:** 5\n**Last used:** 2024-01-17" 4:13-4:26

      
        
        43
        +5:11 "**Commodity:** `EUR`\n**Usage:** 2\n**Last used:** 2024-01-16" 5:10-5:13

      
        
        44
        +6:9 "**Payee:** Grocery Store\n**Transactions:** 2" 6:6-6:19

      
        
        45
        +7:7 "**Tag:** `client`\n**Usage:** 1\n**Values:**\n- `acme`" 7:4-7:10

      
        
        46
        +9:0 "**Date:** 2024-01-15\n\n**Payee:** Grocery Store\n\n**Transactions:** 2\n\n**Postings:** 3" 9:0-9:10

      
        
        47
        +9:14 "**Payee:** Grocery Store\n**Transactions:** 2" 9:11-9:24

      
        
        48
        +9:31 "**Tag:** `project`\n**Usage:** 2\n**Values:**\n- `home`\n- `work`" 9:27-9:34

      
        
        49
        +9:37 "**Tag:** `project`\n**Value:** `home`\n**Usage:** 1" 9:35-9:39

      
        
        50
        +10:19 "**Amount:** $50.00\n\n**Unit cost:** @ $1.5" 10:19-10:25

      
        
        51
        +10:22 "**Amount:** $50.00\n\n**Unit cost:** @ $1.5" 10:19-10:25

      
        
        52
        +10:28 "**Commodity:** `$`\n**Usage:** 10\n**Last used:** 2024-01-17" 10:28-10:29

      
        
        53
        +11:23 "**Amount:** $50.00\n\n**Unit cost:** @ $1.5" 11:21-11:27

      
        
        54
        +12:19 "**Commodity:** `$`\n**Usage:** 10\n**Last used:** 2024-01-17" 12:19-12:20

      
        
        55
        +13:8 "**Tag:** `client`\n**Usage:** 1\n**Values:**\n- `acme`" 13:6-13:12

      
        
        56
        +14:16 "**Tag:** `completed`\n**Value:** *(empty)*\n**Usage:** 1" 14:16-14:16

      
        
        57
        +20:37 "**Tag:** `project`\n**Value:** `work`\n**Usage:** 1" 20:35-20:39

      
        
        58
        +21:29 "**Amount:** 1.000,00 EUR" 21:19-21:31

      
        
        59
        +22:25 "**Amount:** -1.000,00 EUR" 22:17-22:30

      
        
        60
        +24:15 "**Payee:** продукти\n**Transactions:** 1" 24:11-24:19

      
        
        61
        +25:15 "**Account:** `expenses:food`\n**Postings:** 5\n**Last used:** 2024-01-17" 25:4-25:17

      
        
        62
        +26:13 "**Account:** `assets:cash`\n**Postings:** 6\n**Last used:** 2024-01-17" 26:4-26:15

      
        
        63
        +28:0 <none>

      
        
        64
        +30:15 "**Account:** `expenses:food`\n**Postings:** 5\n**Last used:** 2024-01-17" 30:4-30:17

      
        
        65
        +34:13 "**Account:** `assets:cash`\n**Postings:** 6\n**Last used:** 2024-01-17" 34:4-34:15

      
A internal/lsp/textdocument_hover.go
···
        
        1
        +package lsp

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"fmt"

      
        
        6
        +	"strings"

      
        
        7
        +

      
        
        8
        +	"go.lsp.dev/protocol"

      
        
        9
        +

      
        
        10
        +	"olexsmir.xyz/clerk/internal/analyzer"

      
        
        11
        +	"olexsmir.xyz/clerk/internal/lsp/lsputil"

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

      
        
        13
        +	"olexsmir.xyz/clerk/journal/token"

      
        
        14
        +)

      
        
        15
        +

      
        
        16
        +func (s *server) Hover(_ context.Context, params *protocol.HoverParams) (*protocol.Hover, error) {

      
        
        17
        +	state, ok := s.getDocState(params.TextDocument.URI)

      
        
        18
        +	if !ok {

      
        
        19
        +		return nil, nil

      
        
        20
        +	}

      
        
        21
        +

      
        
        22
        +	an := s.analysis()

      
        
        23
        +	cursor := lsputil.Offset(state.text, int(params.Position.Line), int(params.Position.Character))

      
        
        24
        +	el := hoverAt(an, params.TextDocument.URI.Path(), state.text, cursor)

      
        
        25
        +	if el == nil {

      
        
        26
        +		return nil, nil

      
        
        27
        +	}

      
        
        28
        +

      
        
        29
        +	return &protocol.Hover{

      
        
        30
        +		Contents: &protocol.MarkupContent{

      
        
        31
        +			Kind:  protocol.MarkupKindMarkdown,

      
        
        32
        +			Value: buildHoverContent(an, state.text, el),

      
        
        33
        +		},

      
        
        34
        +		Range: new(spanToProtocolRange(state.text, el.span)),

      
        
        35
        +	}, nil

      
        
        36
        +}

      
        
        37
        +

      
        
        38
        +type hoverKind int

      
        
        39
        +

      
        
        40
        +const (

      
        
        41
        +	hoverAccount hoverKind = iota

      
        
        42
        +	hoverCommodity

      
        
        43
        +	hoverPayee

      
        
        44
        +	hoverTagKey

      
        
        45
        +	hoverTagValue

      
        
        46
        +	hoverAmount

      
        
        47
        +	hoverDate

      
        
        48
        +)

      
        
        49
        +

      
        
        50
        +type hoverElement struct {

      
        
        51
        +	kind     hoverKind

      
        
        52
        +	span     token.Span

      
        
        53
        +	name     string // account, commodity or payee name

      
        
        54
        +	tagKey   string

      
        
        55
        +	tagValue string

      
        
        56
        +	amount   *ast.Amount

      
        
        57
        +	cost     *ast.Cost

      
        
        58
        +	tx       *ast.Transaction // date hover

      
        
        59
        +}

      
        
        60
        +

      
        
        61
        +func hoverAt(an *analyzer.Analysis, docPath, content string, cursor int) *hoverElement {

      
        
        62
        +	for _, pf := range an.Files {

      
        
        63
        +		if pf.Path != docPath {

      
        
        64
        +			continue

      
        
        65
        +		}

      
        
        66
        +		for _, entry := range pf.Ast.Entries {

      
        
        67
        +			if el := hoverInEntry(content, entry, cursor); el != nil {

      
        
        68
        +				return el

      
        
        69
        +			}

      
        
        70
        +		}

      
        
        71
        +		return nil

      
        
        72
        +	}

      
        
        73
        +	return nil

      
        
        74
        +}

      
        
        75
        +

      
        
        76
        +func hoverInEntry(content string, e ast.Entry, cursor int) *hoverElement {

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

      
        
        78
        +	case *ast.Transaction:

      
        
        79
        +		if e.Payee != nil && spanContains(content, e.Payee.Span, cursor) {

      
        
        80
        +			return &hoverElement{kind: hoverPayee, span: e.Payee.Span, name: e.Payee.Name}

      
        
        81
        +		}

      
        
        82
        +		if spanContains(content, e.Date.Span, cursor) {

      
        
        83
        +			return &hoverElement{kind: hoverDate, span: e.Date.Span, tx: e}

      
        
        84
        +		}

      
        
        85
        +		if el := hoverTagInComment(content, e.Comment, cursor); el != nil {

      
        
        86
        +			return el

      
        
        87
        +		}

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

      
        
        89
        +			if el := hoverTagInComment(content, c, cursor); el != nil {

      
        
        90
        +				return el

      
        
        91
        +			}

      
        
        92
        +		}

      
        
        93
        +		return hoverInPostings(content, e.Postings, cursor)

      
        
        94
        +	case *ast.PeriodicTransaction:

      
        
        95
        +		if el := hoverTagInComment(content, e.Comment, cursor); el != nil {

      
        
        96
        +			return el

      
        
        97
        +		}

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

      
        
        99
        +			if el := hoverTagInComment(content, c, cursor); el != nil {

      
        
        100
        +				return el

      
        
        101
        +			}

      
        
        102
        +		}

      
        
        103
        +		return hoverInPostings(content, e.Postings, cursor)

      
        
        104
        +	case *ast.AutomatedTransaction:

      
        
        105
        +		if el := hoverTagInComment(content, e.Comment, cursor); el != nil {

      
        
        106
        +			return el

      
        
        107
        +		}

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

      
        
        109
        +			if el := hoverTagInComment(content, c, cursor); el != nil {

      
        
        110
        +				return el

      
        
        111
        +			}

      
        
        112
        +		}

      
        
        113
        +		return hoverInPostings(content, e.Postings, cursor)

      
        
        114
        +	case *ast.Comment:

      
        
        115
        +		return hoverTagInComment(content, e, cursor)

      
        
        116
        +	case *ast.AccountDirective:

      
        
        117
        +		if spanContains(content, e.Account.Span, cursor) {

      
        
        118
        +			return &hoverElement{kind: hoverAccount, span: e.Account.Span, name: e.Account.String()}

      
        
        119
        +		}

      
        
        120
        +		for _, sd := range e.Subdirectives {

      
        
        121
        +			if sd.Name == "alias" && spanContains(content, sd.ValueSpan, cursor) {

      
        
        122
        +				return &hoverElement{kind: hoverAccount, span: sd.ValueSpan, name: sd.Value}

      
        
        123
        +			}

      
        
        124
        +		}

      
        
        125
        +	case *ast.AliasDirective:

      
        
        126
        +		if spanContains(content, e.From.Span, cursor) {

      
        
        127
        +			return &hoverElement{kind: hoverAccount, span: e.From.Span, name: e.From.String()}

      
        
        128
        +		}

      
        
        129
        +		if spanContains(content, e.To.Span, cursor) {

      
        
        130
        +			return &hoverElement{kind: hoverAccount, span: e.To.Span, name: e.To.String()}

      
        
        131
        +		}

      
        
        132
        +	case *ast.CommodityDirective:

      
        
        133
        +		if spanContains(content, e.CommoditySpan, cursor) {

      
        
        134
        +			return &hoverElement{kind: hoverCommodity, span: e.CommoditySpan, name: e.Commodity}

      
        
        135
        +		}

      
        
        136
        +	case *ast.PayeeDirective:

      
        
        137
        +		if e.Name != nil && spanContains(content, e.Name.Span, cursor) {

      
        
        138
        +			return &hoverElement{kind: hoverPayee, span: e.Name.Span, name: e.Name.Name}

      
        
        139
        +		}

      
        
        140
        +	case *ast.TagDirective:

      
        
        141
        +		if e.Name != "" {

      
        
        142
        +			if span, ok := tagDirectiveSpan(content, e); ok && spanContains(content, span, cursor) {

      
        
        143
        +				return &hoverElement{kind: hoverTagKey, span: span, tagKey: e.Name}

      
        
        144
        +			}

      
        
        145
        +		}

      
        
        146
        +	}

      
        
        147
        +	return nil

      
        
        148
        +}

      
        
        149
        +

      
        
        150
        +func hoverInPostings(content string, postings []*ast.Posting, cursor int) *hoverElement {

      
        
        151
        +	for _, p := range postings {

      
        
        152
        +		if spanContains(content, p.Account.Span, cursor) {

      
        
        153
        +			return &hoverElement{kind: hoverAccount, span: p.Account.Span, name: p.Account.String()}

      
        
        154
        +		}

      
        
        155
        +		if p.Amount != nil {

      
        
        156
        +			if spanContains(content, p.Amount.Span, cursor) {

      
        
        157
        +				return &hoverElement{kind: hoverAmount, span: p.Amount.Span, amount: p.Amount, cost: p.Cost}

      
        
        158
        +			}

      
        
        159
        +			if ref := commodityRef(content, p.Amount, cursor); ref != nil {

      
        
        160
        +				return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name}

      
        
        161
        +			}

      
        
        162
        +		}

      
        
        163
        +		if p.Cost != nil {

      
        
        164
        +			if ref := commodityRef(content, &p.Cost.Amount, cursor); ref != nil {

      
        
        165
        +				return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name}

      
        
        166
        +			}

      
        
        167
        +		}

      
        
        168
        +		if p.Balance != nil {

      
        
        169
        +			if ref := commodityRef(content, &p.Balance.Amount, cursor); ref != nil {

      
        
        170
        +				return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name}

      
        
        171
        +			}

      
        
        172
        +			if p.Balance.Cost != nil {

      
        
        173
        +				if ref := commodityRef(content, &p.Balance.Cost.Amount, cursor); ref != nil {

      
        
        174
        +					return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name}

      
        
        175
        +				}

      
        
        176
        +			}

      
        
        177
        +		}

      
        
        178
        +		if el := hoverTagInComment(content, p.Comment, cursor); el != nil {

      
        
        179
        +			return el

      
        
        180
        +		}

      
        
        181
        +		for i := range p.Comments {

      
        
        182
        +			if el := hoverTagInComment(content, &p.Comments[i], cursor); el != nil {

      
        
        183
        +				return el

      
        
        184
        +			}

      
        
        185
        +		}

      
        
        186
        +	}

      
        
        187
        +	return nil

      
        
        188
        +}

      
        
        189
        +

      
        
        190
        +func hoverTagInComment(content string, c *ast.Comment, cursor int) *hoverElement {

      
        
        191
        +	if c == nil {

      
        
        192
        +		return nil

      
        
        193
        +	}

      
        
        194
        +	for i := range c.Tags {

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

      
        
        196
        +		if !spanContains(content, t.Span, cursor) {

      
        
        197
        +			continue

      
        
        198
        +		}

      
        
        199
        +		keySpan := tagKeySpan(content, t)

      
        
        200
        +		if spanContains(content, keySpan, cursor) {

      
        
        201
        +			return &hoverElement{kind: hoverTagKey, span: keySpan, tagKey: t.Key}

      
        
        202
        +		}

      
        
        203
        +		return &hoverElement{kind: hoverTagValue, span: tagValueSpan(content, t), tagKey: t.Key, tagValue: t.Value}

      
        
        204
        +	}

      
        
        205
        +	return nil

      
        
        206
        +}

      
        
        207
        +

      
        
        208
        +func buildHoverContent(an *analyzer.Analysis, content string, el *hoverElement) string {

      
        
        209
        +	switch el.kind {

      
        
        210
        +	case hoverAccount:

      
        
        211
        +		return buildAccountHover(an, el.name)

      
        
        212
        +	case hoverCommodity:

      
        
        213
        +		return buildCommodityHover(an, el.name)

      
        
        214
        +	case hoverPayee:

      
        
        215
        +		return buildPayeeHover(an, el.name)

      
        
        216
        +	case hoverTagKey:

      
        
        217
        +		return buildTagHover(an, el.tagKey)

      
        
        218
        +	case hoverTagValue:

      
        
        219
        +		return buildTagValueHover(an, el.tagKey, el.tagValue)

      
        
        220
        +	case hoverAmount:

      
        
        221
        +		return buildAmountHover(content, el.amount, el.cost)

      
        
        222
        +	case hoverDate:

      
        
        223
        +		return buildDateHover(an, el.tx)

      
        
        224
        +	default:

      
        
        225
        +		return ""

      
        
        226
        +	}

      
        
        227
        +}

      
        
        228
        +

      
        
        229
        +func buildAccountHover(an *analyzer.Analysis, name string) string {

      
        
        230
        +	if canon, ok := an.AccountAliases[name]; ok {

      
        
        231
        +		name = canon

      
        
        232
        +	}

      
        
        233
        +	var sb strings.Builder

      
        
        234
        +	fmt.Fprintf(&sb, "**Account:** `%s`", name)

      
        
        235
        +	if info := an.Accounts[name]; info != nil {

      
        
        236
        +		fmt.Fprintf(&sb, "\n**Postings:** %d", info.UsedCount)

      
        
        237
        +		writeDateSection(&sb, "**Last used:**", info.LastUsed)

      
        
        238
        +	}

      
        
        239
        +	return sb.String()

      
        
        240
        +}

      
        
        241
        +

      
        
        242
        +func buildCommodityHover(an *analyzer.Analysis, symbol string) string {

      
        
        243
        +	var sb strings.Builder

      
        
        244
        +	fmt.Fprintf(&sb, "**Commodity:** `%s`", symbol)

      
        
        245
        +	if info := an.Commodities[symbol]; info != nil {

      
        
        246
        +		fmt.Fprintf(&sb, "\n**Usage:** %d", info.UsedCount)

      
        
        247
        +		writeDateSection(&sb, "**Last used:**", info.LastUsed)

      
        
        248
        +	}

      
        
        249
        +	return sb.String()

      
        
        250
        +}

      
        
        251
        +

      
        
        252
        +func buildPayeeHover(an *analyzer.Analysis, name string) string {

      
        
        253
        +	var sb strings.Builder

      
        
        254
        +	fmt.Fprintf(&sb, "**Payee:** %s", name)

      
        
        255
        +	if info := an.Payees[name]; info != nil {

      
        
        256
        +		fmt.Fprintf(&sb, "\n**Transactions:** %d", info.UsedCount)

      
        
        257
        +	}

      
        
        258
        +	return sb.String()

      
        
        259
        +}

      
        
        260
        +

      
        
        261
        +func buildTagHover(an *analyzer.Analysis, key string) string {

      
        
        262
        +	var sb strings.Builder

      
        
        263
        +	fmt.Fprintf(&sb, "**Tag:** `%s`", key)

      
        
        264
        +	if info := an.Tags[key]; info != nil {

      
        
        265
        +		fmt.Fprintf(&sb, "\n**Usage:** %d", info.UsedCount)

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

      
        
        267
        +			sb.WriteString("\n**Values:**\n")

      
        
        268
        +			for i, v := range info.Values {

      
        
        269
        +				if i > 0 {

      
        
        270
        +					sb.WriteByte('\n')

      
        
        271
        +				}

      
        
        272
        +				if v == "" {

      
        
        273
        +					sb.WriteString("- *(empty)*")

      
        
        274
        +				} else {

      
        
        275
        +					fmt.Fprintf(&sb, "- `%s`", v)

      
        
        276
        +				}

      
        
        277
        +			}

      
        
        278
        +		}

      
        
        279
        +	}

      
        
        280
        +	return sb.String()

      
        
        281
        +}

      
        
        282
        +

      
        
        283
        +func buildTagValueHover(an *analyzer.Analysis, key, value string) string {

      
        
        284
        +	var sb strings.Builder

      
        
        285
        +	fmt.Fprintf(&sb, "**Tag:** `%s`", key)

      
        
        286
        +	if value == "" {

      
        
        287
        +		sb.WriteString("\n**Value:** *(empty)*")

      
        
        288
        +	} else {

      
        
        289
        +		fmt.Fprintf(&sb, "\n**Value:** `%s`", value)

      
        
        290
        +	}

      
        
        291
        +	if info := an.Tags[key]; info != nil {

      
        
        292
        +		count := 0

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

      
        
        294
        +			if u.Tag.Value == value {

      
        
        295
        +				count++

      
        
        296
        +			}

      
        
        297
        +		}

      
        
        298
        +		fmt.Fprintf(&sb, "\n**Usage:** %d", count)

      
        
        299
        +	}

      
        
        300
        +	return sb.String()

      
        
        301
        +}

      
        
        302
        +

      
        
        303
        +func buildAmountHover(content string, am *ast.Amount, cost *ast.Cost) string {

      
        
        304
        +	amountText := strings.TrimRight(content[am.Span.Start.Offset:am.Span.End.Offset], " \t")

      
        
        305
        +	var sb strings.Builder

      
        
        306
        +	fmt.Fprintf(&sb, "**Amount:** %s", amountText)

      
        
        307
        +	if cost != nil {

      
        
        308
        +		costText := strings.TrimRight(content[cost.Amount.Span.Start.Offset:cost.Amount.Span.End.Offset], " \t")

      
        
        309
        +		label, marker := "**Unit cost:**", "@"

      
        
        310
        +		if cost.IsTotal {

      
        
        311
        +			label, marker = "**Total cost:**", "@@"

      
        
        312
        +		}

      
        
        313
        +		fmt.Fprintf(&sb, "\n\n%s %s %s", label, marker, costText)

      
        
        314
        +	}

      
        
        315
        +	return sb.String()

      
        
        316
        +}

      
        
        317
        +

      
        
        318
        +func buildDateHover(an *analyzer.Analysis, tx *ast.Transaction) string {

      
        
        319
        +	var sb strings.Builder

      
        
        320
        +	fmt.Fprintf(&sb, "**Date:** %04d-%02d-%02d", tx.Date.Year, tx.Date.Month, tx.Date.Day)

      
        
        321
        +	if tx.Payee != nil {

      
        
        322
        +		fmt.Fprintf(&sb, "\n\n**Payee:** %s", tx.Payee.Name)

      
        
        323
        +	}

      
        
        324
        +	fmt.Fprintf(&sb, "\n\n**Transactions:** %d", countTransactionsOnDate(an, tx.Date))

      
        
        325
        +	fmt.Fprintf(&sb, "\n\n**Postings:** %d", len(tx.Postings))

      
        
        326
        +	return sb.String()

      
        
        327
        +}

      
        
        328
        +

      
        
        329
        +func countTransactionsOnDate(an *analyzer.Analysis, d ast.Date) int {

      
        
        330
        +	count := 0

      
        
        331
        +	for _, tx := range an.Transactions {

      
        
        332
        +		if tx.Date.Compare(d) == 0 {

      
        
        333
        +			count++

      
        
        334
        +		}

      
        
        335
        +	}

      
        
        336
        +	return count

      
        
        337
        +}

      
        
        338
        +

      
        
        339
        +func writeDateSection(sb *strings.Builder, label string, d ast.Date) {

      
        
        340
        +	if d.Year == 0 {

      
        
        341
        +		return

      
        
        342
        +	}

      
        
        343
        +	fmt.Fprintf(sb, "\n%s %04d-%02d-%02d", label, d.Year, d.Month, d.Day)

      
        
        344
        +}

      
        
        345
        +

      
        
        346
        +func tagValueSpan(content string, t *ast.Tag) token.Span {

      
        
        347
        +	start := tagKeySpan(content, t).End.Offset

      
        
        348
        +	if start < t.Span.End.Offset && content[start] == ':' {

      
        
        349
        +		start++

      
        
        350
        +	}

      
        
        351
        +	end := t.Span.End.Offset

      
        
        352
        +	for end > start && (content[end-1] == ' ' || content[end-1] == '\t') {

      
        
        353
        +		end--

      
        
        354
        +	}

      
        
        355
        +	return token.Span{Start: offsetPos(t.Span.Start.File, start), End: offsetPos(t.Span.Start.File, end)}

      
        
        356
        +}

      
A internal/lsp/textdocument_hover_test.go
···
        
        1
        +package lsp

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"fmt"

      
        
        6
        +	"os"

      
        
        7
        +	"strings"

      
        
        8
        +	"testing"

      
        
        9
        +	"time"

      
        
        10
        +

      
        
        11
        +	"go.lsp.dev/protocol"

      
        
        12
        +	"go.lsp.dev/uri"

      
        
        13
        +

      
        
        14
        +	"olexsmir.xyz/clerk/internal/analyzer"

      
        
        15
        +	"olexsmir.xyz/clerk/internal/lsp/lsputil"

      
        
        16
        +	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        
        17
        +	"olexsmir.xyz/clerk/journal"

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

      
        
        19
        +	"olexsmir.xyz/clerk/journal/token"

      
        
        20
        +)

      
        
        21
        +

      
        
        22
        +func TestTagValueSpan(t *testing.T) {

      
        
        23
        +	tag := func(start, end int) *ast.Tag {

      
        
        24
        +		return &ast.Tag{

      
        
        25
        +			Span: token.Span{Start: token.Pos{Offset: start}, End: token.Pos{Offset: end}},

      
        
        26
        +		}

      
        
        27
        +	}

      
        
        28
        +

      
        
        29
        +	tests := map[string]struct {

      
        
        30
        +		content            string

      
        
        31
        +		tag                *ast.Tag

      
        
        32
        +		wantStart, wantEnd int

      
        
        33
        +	}{

      
        
        34
        +		"value":                {"; project:home", tag(2, 14), 10, 14},

      
        
        35
        +		"empty value":          {"; done:", tag(2, 7), 7, 7},

      
        
        36
        +		"trailing space":       {"; done: ", tag(2, 8), 7, 7},

      
        
        37
        +		"second of comma list": {"; a:1, b:2", tag(7, 10), 9, 10},

      
        
        38
        +	}

      
        
        39
        +	for tname, tt := range tests {

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

      
        
        41
        +			span := tagValueSpan(tt.content, tt.tag)

      
        
        42
        +			if span.Start.Offset != tt.wantStart || span.End.Offset != tt.wantEnd {

      
        
        43
        +				t.Errorf("got %d-%d, want %d-%d", span.Start.Offset, span.End.Offset, tt.wantStart, tt.wantEnd)

      
        
        44
        +			}

      
        
        45
        +		})

      
        
        46
        +	}

      
        
        47
        +}

      
        
        48
        +

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

      
        
        50
        +	tx := func(y, m, d int) *ast.Transaction { return &ast.Transaction{Date: ast.Date{Year: y, Month: m, Day: d}} }

      
        
        51
        +	an := &analyzer.Analysis{Transactions: []*ast.Transaction{tx(2024, 1, 15), tx(2024, 1, 15), tx(2024, 2, 1)}}

      
        
        52
        +	if got := countTransactionsOnDate(an, ast.Date{Year: 2024, Month: 1, Day: 15}); got != 2 {

      
        
        53
        +		t.Errorf("got %d, want 2", got)

      
        
        54
        +	}

      
        
        55
        +	if got := countTransactionsOnDate(an, ast.Date{Year: 2025, Month: 1, Day: 15}); got != 0 {

      
        
        56
        +		t.Errorf("got %d, want 0", got)

      
        
        57
        +	}

      
        
        58
        +}

      
        
        59
        +

      
        
        60
        +func TestServerHover_DocumentNotFound(t *testing.T) {

      
        
        61
        +	srv := NewServer("test")

      
        
        62
        +	res, err := srv.server.Hover(context.Background(), &protocol.HoverParams{

      
        
        63
        +		TextDocumentPositionParams: protocol.TextDocumentPositionParams{

      
        
        64
        +			TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")},

      
        
        65
        +			Position:     protocol.Position{Line: 0, Character: 0},

      
        
        66
        +		},

      
        
        67
        +	})

      
        
        68
        +	if err != nil {

      
        
        69
        +		t.Fatal(err)

      
        
        70
        +	}

      
        
        71
        +	if res != nil {

      
        
        72
        +		t.Errorf("got %v, want nil", res)

      
        
        73
        +	}

      
        
        74
        +}

      
        
        75
        +

      
        
        76
        +// Golden

      
        
        77
        +

      
        
        78
        +func TestHoverTxtar(t *testing.T) {

      
        
        79
        +	for _, tt := range []string{"hover-journal", "hover-include"} {

      
        
        80
        +		ar := golden.Read(t, tt)

      
        
        81
        +		t.Run(tt, func(t *testing.T) {

      
        
        82
        +			h := newTxtarHarness(t, ar)

      
        
        83
        +

      
        
        84
        +			var b strings.Builder

      
        
        85
        +			for i := range h.cursors {

      
        
        86
        +				tdp := h.textDocumentPosition(i)

      
        
        87
        +				res, err := h.srv.Hover(t.Context(), &protocol.HoverParams{

      
        
        88
        +					TextDocumentPositionParams: tdp,

      
        
        89
        +				})

      
        
        90
        +				if err != nil {

      
        
        91
        +					t.Fatal(err)

      
        
        92
        +				}

      
        
        93
        +				if res == nil {

      
        
        94
        +					fmt.Fprintf(&b, "%d:%d <none>\n", tdp.Position.Line, tdp.Position.Character)

      
        
        95
        +					continue

      
        
        96
        +				}

      
        
        97
        +				mc, ok := res.Contents.(*protocol.MarkupContent)

      
        
        98
        +				if !ok {

      
        
        99
        +					t.Fatalf("Hover returned %T, want *MarkupContent", res.Contents)

      
        
        100
        +				}

      
        
        101
        +				fmt.Fprintf(&b, "%d:%d %q %d:%d-%d:%d\n",

      
        
        102
        +					tdp.Position.Line, tdp.Position.Character, mc.Value,

      
        
        103
        +					res.Range.Start.Line, res.Range.Start.Character, res.Range.End.Line, res.Range.End.Character)

      
        
        104
        +			}

      
        
        105
        +			golden.Assert(t, ar, b.String())

      
        
        106
        +		})

      
        
        107
        +	}

      
        
        108
        +}

      
        
        109
        +

      
        
        110
        +func BenchmarkHover(b *testing.B) {

      
        
        111
        +	path := "../../journal/testdata/journals/actual-1ktxns-100accts.journal"

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

      
        
        113
        +	if err != nil {

      
        
        114
        +		b.Fatal(err)

      
        
        115
        +	}

      
        
        116
        +	content := string(src)

      
        
        117
        +

      
        
        118
        +	// Path must match the open doc: hoverAt resolves the document by path.

      
        
        119
        +	an := analyzer.Build(journal.NewLoader().ResolveBytes("/test.journal", src))

      
        
        120
        +

      
        
        121
        +	srv := NewServer("test")

      
        
        122
        +	srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")

      
        
        123
        +	srv.server.current = an

      
        
        124
        +

      
        
        125
        +	for tname, tt := range map[string]int{

      
        
        126
        +		"date":              strings.Index(content, "2000-01-01 transaction 1") + 3,

      
        
        127
        +		"account":           strings.Index(content, "\n  1:2:3 ") + len("\n  ") + 2,

      
        
        128
        +		"payee":             strings.Index(content, "transaction 1") + len("transaction ") + 1,

      
        
        129
        +		"amount with cost":  strings.Index(content, "2 B @@") + 2,

      
        
        130
        +		"commodity in cost": strings.Index(content, "@@ 2 C") + len("@@ 2 C") - 1,

      
        
        131
        +		"late amount":       strings.Index(content, "1000 L @") + 2,

      
        
        132
        +	} {

      
        
        133
        +		b.Run(tname, func(b *testing.B) {

      
        
        134
        +			line, col := lsputil.LineCol(content, tt)

      
        
        135
        +			params := &protocol.HoverParams{

      
        
        136
        +				TextDocumentPositionParams: protocol.TextDocumentPositionParams{

      
        
        137
        +					TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")},

      
        
        138
        +					Position:     protocol.Position{Line: uint32(line), Character: uint32(col)},

      
        
        139
        +				},

      
        
        140
        +			}

      
        
        141
        +

      
        
        142
        +			// warm up

      
        
        143
        +			if _, err := srv.server.Hover(b.Context(), params); err != nil {

      
        
        144
        +				b.Fatal(err)

      
        
        145
        +			}

      
        
        146
        +			b.ReportAllocs()

      
        
        147
        +			b.ResetTimer()

      
        
        148
        +			for b.Loop() {

      
        
        149
        +				if _, err := srv.server.Hover(b.Context(), params); err != nil {

      
        
        150
        +					b.Fatal(err)

      
        
        151
        +				}

      
        
        152
        +			}

      
        
        153
        +

      
        
        154
        +			// guard: hover walks entries from the top of the file, so a cursor

      
        
        155
        +			// near the end costs ~0.8ms on this file; a whole-file re-parse per

      
        
        156
        +			// request (~13ms) would blow past this and must be caught

      
        
        157
        +			if avg := b.Elapsed() / time.Duration(b.N); avg > 5*time.Millisecond {

      
        
        158
        +				b.Fatalf("hover %v/op: entry walk or reparse regression", avg)

      
        
        159
        +			}

      
        
        160
        +		})

      
        
        161
        +	}

      
        
        162
        +}