all repos

clerk @ master

missing tooling for ledger/hledger

clerk/internal/lsp/textdocument_hover.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
fix typos and grammar, 9 days ago
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/journal/ast"
12
	"olexsmir.xyz/clerk/journal/token"
13
)
14
15
func (s *server) Hover(_ context.Context, params *protocol.HoverParams) (*protocol.Hover, error) {
16
	state, ok := s.getDocState(params.TextDocument.URI)
17
	if !ok {
18
		return nil, nil
19
	}
20
21
	an := s.analysisFor(params.TextDocument.URI)
22
	cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character))
23
	el := hoverAt(an, params.TextDocument.URI.Path(), state.text, cursor)
24
	if el == nil {
25
		return nil, nil
26
	}
27
28
	return &protocol.Hover{
29
		Contents: &protocol.MarkupContent{
30
			Kind:  protocol.MarkupKindMarkdown,
31
			Value: buildHoverContent(an, state.text, el),
32
		},
33
		Range: new(state.lineIdx.SpanRange(el.span)),
34
	}, nil
35
}
36
37
type hoverKind int
38
39
const (
40
	hoverAccount hoverKind = iota
41
	hoverCommodity
42
	hoverPayee
43
	hoverTagKey
44
	hoverTagValue
45
	hoverAmount
46
	hoverDate
47
)
48
49
type hoverElement struct {
50
	kind     hoverKind
51
	span     token.Span
52
	name     string // account, commodity or payee name
53
	tagKey   string
54
	tagValue string
55
	amount   *ast.Amount
56
	cost     *ast.Cost
57
	tx       *ast.Transaction // date hover
58
}
59
60
// hoverAt finds the hover target under the cursor in the document matching
61
// docPath. Only the entry containing the cursor can match; entries are in
62
// file order, so [entryAt] finds the containing entry in O(log n).
63
func hoverAt(an *analyzer.Analysis, docPath, content string, cursor int) *hoverElement {
64
	pf := parsedFileFor(an, docPath)
65
	if pf == nil {
66
		return nil
67
	}
68
	if entry := entryAt(pf.Ast.Entries, cursor); entry != nil {
69
		return hoverInEntry(content, entry, cursor)
70
	}
71
	return nil
72
}
73
74
func hoverInEntry(content string, e ast.Entry, cursor int) *hoverElement {
75
	switch e := e.(type) {
76
	case *ast.Transaction:
77
		if e.Payee != "" && spanContains(content, e.PayeeSpan, cursor) {
78
			return &hoverElement{kind: hoverPayee, span: e.PayeeSpan, name: e.Payee}
79
		}
80
		if spanContains(content, e.Date.Span, cursor) {
81
			return &hoverElement{kind: hoverDate, span: e.Date.Span, tx: e}
82
		}
83
		return hoverInTransaction(content, e.Comment, e.HeaderComments, e.Postings, cursor)
84
	case *ast.PeriodicTransaction:
85
		return hoverInTransaction(content, e.Comment, e.HeaderComments, e.Postings, cursor)
86
	case *ast.AutomatedTransaction:
87
		return hoverInTransaction(content, e.Comment, e.HeaderComments, e.Postings, cursor)
88
	case *ast.Comment:
89
		return hoverTagInComment(content, e, cursor)
90
	case *ast.AccountDirective:
91
		if spanContains(content, e.Account.Span, cursor) {
92
			return &hoverElement{kind: hoverAccount, span: e.Account.Span, name: e.Account.String()}
93
		}
94
		for _, sd := range e.Subdirectives {
95
			if sd.Kind == ast.SubdirectiveAlias && spanContains(content, sd.ValueSpan, cursor) {
96
				return &hoverElement{kind: hoverAccount, span: sd.ValueSpan, name: sd.Value}
97
			}
98
		}
99
	case *ast.AliasDirective:
100
		if spanContains(content, e.From.Span, cursor) {
101
			return &hoverElement{kind: hoverAccount, span: e.From.Span, name: e.From.String()}
102
		}
103
		if spanContains(content, e.To.Span, cursor) {
104
			return &hoverElement{kind: hoverAccount, span: e.To.Span, name: e.To.String()}
105
		}
106
	case *ast.CommodityDirective:
107
		if spanContains(content, e.CommoditySpan, cursor) {
108
			return &hoverElement{kind: hoverCommodity, span: e.CommoditySpan, name: e.Commodity}
109
		}
110
	case *ast.PayeeDirective:
111
		if e.Name != "" && spanContains(content, e.NameSpan, cursor) {
112
			return &hoverElement{kind: hoverPayee, span: e.NameSpan, name: e.Name}
113
		}
114
	case *ast.TagDirective:
115
		if e.Name != "" {
116
			if span, ok := tagDirectiveSpan(content, e); ok && spanContains(content, span, cursor) {
117
				return &hoverElement{kind: hoverTagKey, span: span, tagKey: e.Name}
118
			}
119
		}
120
	}
121
	return nil
122
}
123
124
func hoverInTransaction(content string, comment *ast.Comment, header []*ast.Comment, postings []ast.Posting, cursor int) *hoverElement {
125
	if el := hoverTagInComment(content, comment, cursor); el != nil {
126
		return el
127
	}
128
	for _, c := range header {
129
		if el := hoverTagInComment(content, c, cursor); el != nil {
130
			return el
131
		}
132
	}
133
	return hoverInPostings(content, postings, cursor)
134
}
135
136
func hoverInPostings(content string, postings []ast.Posting, cursor int) *hoverElement {
137
	for _, p := range postings {
138
		if spanContains(content, p.Account.Span, cursor) {
139
			return &hoverElement{kind: hoverAccount, span: p.Account.Span, name: p.Account.String()}
140
		}
141
		if p.Amount != nil {
142
			if spanContains(content, p.Amount.Span, cursor) {
143
				return &hoverElement{kind: hoverAmount, span: p.Amount.Span, amount: p.Amount, cost: p.Cost}
144
			}
145
			if ref := commodityRef(content, p.Amount, cursor); ref != nil {
146
				return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name}
147
			}
148
		}
149
		if p.Cost != nil {
150
			if ref := commodityRef(content, &p.Cost.Amount, cursor); ref != nil {
151
				return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name}
152
			}
153
		}
154
		if p.Balance != nil {
155
			if ref := commodityRef(content, &p.Balance.Amount, cursor); ref != nil {
156
				return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name}
157
			}
158
			if p.Balance.Cost != nil {
159
				if ref := commodityRef(content, &p.Balance.Cost.Amount, cursor); ref != nil {
160
					return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name}
161
				}
162
			}
163
		}
164
		if el := hoverTagInComment(content, p.Comment, cursor); el != nil {
165
			return el
166
		}
167
		for i := range p.Comments {
168
			if el := hoverTagInComment(content, &p.Comments[i], cursor); el != nil {
169
				return el
170
			}
171
		}
172
	}
173
	return nil
174
}
175
176
func hoverTagInComment(content string, c *ast.Comment, cursor int) *hoverElement {
177
	if c == nil {
178
		return nil
179
	}
180
	for i := range c.Tags {
181
		t := &c.Tags[i]
182
		if !spanContains(content, t.Span, cursor) {
183
			continue
184
		}
185
		keySpan := tagKeySpan(content, t)
186
		if spanContains(content, keySpan, cursor) {
187
			return &hoverElement{kind: hoverTagKey, span: keySpan, tagKey: t.Key}
188
		}
189
		return &hoverElement{kind: hoverTagValue, span: tagValueSpan(content, t), tagKey: t.Key, tagValue: t.Value}
190
	}
191
	return nil
192
}
193
194
func buildHoverContent(an *analyzer.Analysis, content string, el *hoverElement) string {
195
	switch el.kind {
196
	case hoverAccount:
197
		return buildAccountHover(an, el.name)
198
	case hoverCommodity:
199
		return buildCommodityHover(an, el.name)
200
	case hoverPayee:
201
		return buildPayeeHover(an, el.name)
202
	case hoverTagKey:
203
		return buildTagHover(an, el.tagKey)
204
	case hoverTagValue:
205
		return buildTagValueHover(an, el.tagKey, el.tagValue)
206
	case hoverAmount:
207
		return buildAmountHover(content, el.amount, el.cost)
208
	case hoverDate:
209
		return buildDateHover(an, el.tx)
210
	default:
211
		return ""
212
	}
213
}
214
215
func buildAccountHover(an *analyzer.Analysis, name string) string {
216
	if canon, ok := an.AccountAliases[name]; ok {
217
		name = canon
218
	}
219
	var sb strings.Builder
220
	fmt.Fprintf(&sb, "**Account:** `%s`", name)
221
	if info := an.Accounts[name]; info != nil {
222
		fmt.Fprintf(&sb, "\n**Postings:** %d", info.UsedCount)
223
		writeDateSection(&sb, "**Last used:**", info.LastUsed)
224
	}
225
	return sb.String()
226
}
227
228
func buildCommodityHover(an *analyzer.Analysis, symbol string) string {
229
	var sb strings.Builder
230
	fmt.Fprintf(&sb, "**Commodity:** `%s`", symbol)
231
	if info := an.Commodities[symbol]; info != nil {
232
		fmt.Fprintf(&sb, "\n**Usage:** %d", info.UsedCount)
233
		writeDateSection(&sb, "**Last used:**", info.LastUsed)
234
	}
235
	return sb.String()
236
}
237
238
func buildPayeeHover(an *analyzer.Analysis, name string) string {
239
	var sb strings.Builder
240
	fmt.Fprintf(&sb, "**Payee:** %s", name)
241
	if info := an.Payees[name]; info != nil {
242
		fmt.Fprintf(&sb, "\n**Transactions:** %d", info.UsedCount)
243
	}
244
	return sb.String()
245
}
246
247
func buildTagHover(an *analyzer.Analysis, key string) string {
248
	var sb strings.Builder
249
	fmt.Fprintf(&sb, "**Tag:** `%s`", key)
250
	if info := an.Tags[key]; info != nil {
251
		fmt.Fprintf(&sb, "\n**Usage:** %d", info.UsedCount)
252
		if len(info.Values) > 0 {
253
			sb.WriteString("\n**Values:**\n")
254
			for i, v := range info.Values {
255
				if i > 0 {
256
					sb.WriteByte('\n')
257
				}
258
				if v == "" {
259
					sb.WriteString("- *(empty)*")
260
				} else {
261
					fmt.Fprintf(&sb, "- `%s`", v)
262
				}
263
			}
264
		}
265
	}
266
	return sb.String()
267
}
268
269
func buildTagValueHover(an *analyzer.Analysis, key, value string) string {
270
	var sb strings.Builder
271
	fmt.Fprintf(&sb, "**Tag:** `%s`", key)
272
	if value == "" {
273
		sb.WriteString("\n**Value:** *(empty)*")
274
	} else {
275
		fmt.Fprintf(&sb, "\n**Value:** `%s`", value)
276
	}
277
	if info := an.Tags[key]; info != nil {
278
		count := 0
279
		for _, u := range info.Usage {
280
			if u.Tag.Value == value {
281
				count++
282
			}
283
		}
284
		fmt.Fprintf(&sb, "\n**Usage:** %d", count)
285
	}
286
	return sb.String()
287
}
288
289
func buildAmountHover(content string, am *ast.Amount, cost *ast.Cost) string {
290
	amountText := strings.TrimRight(content[am.Span.Start.Offset:am.Span.End.Offset], " \t")
291
	var sb strings.Builder
292
	fmt.Fprintf(&sb, "**Amount:** %s", amountText)
293
	if cost != nil {
294
		costText := strings.TrimRight(content[cost.Amount.Span.Start.Offset:cost.Amount.Span.End.Offset], " \t")
295
		label, marker := "**Unit cost:**", "@"
296
		if cost.IsTotal {
297
			label, marker = "**Total cost:**", "@@"
298
		}
299
		fmt.Fprintf(&sb, "\n\n%s %s %s", label, marker, costText)
300
	}
301
	return sb.String()
302
}
303
304
func buildDateHover(an *analyzer.Analysis, tx *ast.Transaction) string {
305
	var sb strings.Builder
306
	fmt.Fprintf(&sb, "**Date:** %04d-%02d-%02d", tx.Date.Year, tx.Date.Month, tx.Date.Day)
307
	if tx.Payee != "" {
308
		fmt.Fprintf(&sb, "\n\n**Payee:** %s", tx.Payee)
309
	}
310
	fmt.Fprintf(&sb, "\n\n**Transactions:** %d", an.CountTransactionsOnDate(tx.Date))
311
	fmt.Fprintf(&sb, "\n\n**Postings:** %d", len(tx.Postings))
312
	return sb.String()
313
}
314
315
func writeDateSection(sb *strings.Builder, label string, d ast.Date) {
316
	if d.Year == 0 {
317
		return
318
	}
319
	fmt.Fprintf(sb, "\n%s %04d-%02d-%02d", label, d.Year, d.Month, d.Day)
320
}
321
322
func tagValueSpan(content string, t *ast.Tag) token.Span {
323
	start := tagKeySpan(content, t).End.Offset
324
	if start < t.Span.End.Offset && content[start] == ':' {
325
		start++
326
	}
327
	end := t.Span.End.Offset
328
	for end > start && (content[end-1] == ' ' || content[end-1] == '\t') {
329
		end--
330
	}
331
	return token.Span{File: t.Span.File, Start: token.Pos{Offset: start}, End: token.Pos{Offset: end}}
332
}