all repos

clerk @ b36147319e95509014992fd3832247e82733d6c6

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
ast: add enum for subdirective kinds, 1 month 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
	for _, pf := range an.Files {
65
		if pf.Path != docPath {
66
			continue
67
		}
68
		if entry := entryAt(pf.Ast.Entries, cursor); entry != nil {
69
			return hoverInEntry(content, entry, cursor)
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.Kind == ast.SubdirectiveAlias && 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", an.CountTransactionsOnDate(tx.Date))
325
	fmt.Fprintf(&sb, "\n\n**Postings:** %d", len(tx.Postings))
326
	return sb.String()
327
}
328
329
func writeDateSection(sb *strings.Builder, label string, d ast.Date) {
330
	if d.Year == 0 {
331
		return
332
	}
333
	fmt.Fprintf(sb, "\n%s %04d-%02d-%02d", label, d.Year, d.Month, d.Day)
334
}
335
336
func tagValueSpan(content string, t *ast.Tag) token.Span {
337
	start := tagKeySpan(content, t).End.Offset
338
	if start < t.Span.End.Offset && content[start] == ':' {
339
		start++
340
	}
341
	end := t.Span.End.Offset
342
	for end > start && (content[end-1] == ' ' || content[end-1] == '\t') {
343
		end--
344
	}
345
	return token.Span{Start: offsetPos(t.Span.Start.File, start), End: offsetPos(t.Span.Start.File, end)}
346
}