all repos

clerk @ d3fcad3

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
ast: inline leaf value+span wrapper structs, 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
	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
		if el := hoverTagInComment(content, e.Comment, cursor); el != nil {
84
			return el
85
		}
86
		for _, c := range e.HeaderComments {
87
			if el := hoverTagInComment(content, c, cursor); el != nil {
88
				return el
89
			}
90
		}
91
		return hoverInPostings(content, e.Postings, cursor)
92
	case *ast.PeriodicTransaction:
93
		if el := hoverTagInComment(content, e.Comment, cursor); el != nil {
94
			return el
95
		}
96
		for _, c := range e.HeaderComments {
97
			if el := hoverTagInComment(content, c, cursor); el != nil {
98
				return el
99
			}
100
		}
101
		return hoverInPostings(content, e.Postings, cursor)
102
	case *ast.AutomatedTransaction:
103
		if el := hoverTagInComment(content, e.Comment, cursor); el != nil {
104
			return el
105
		}
106
		for _, c := range e.HeaderComments {
107
			if el := hoverTagInComment(content, c, cursor); el != nil {
108
				return el
109
			}
110
		}
111
		return hoverInPostings(content, e.Postings, cursor)
112
	case *ast.Comment:
113
		return hoverTagInComment(content, e, cursor)
114
	case *ast.AccountDirective:
115
		if spanContains(content, e.Account.Span, cursor) {
116
			return &hoverElement{kind: hoverAccount, span: e.Account.Span, name: e.Account.String()}
117
		}
118
		for _, sd := range e.Subdirectives {
119
			if sd.Kind == ast.SubdirectiveAlias && spanContains(content, sd.ValueSpan, cursor) {
120
				return &hoverElement{kind: hoverAccount, span: sd.ValueSpan, name: sd.Value}
121
			}
122
		}
123
	case *ast.AliasDirective:
124
		if spanContains(content, e.From.Span, cursor) {
125
			return &hoverElement{kind: hoverAccount, span: e.From.Span, name: e.From.String()}
126
		}
127
		if spanContains(content, e.To.Span, cursor) {
128
			return &hoverElement{kind: hoverAccount, span: e.To.Span, name: e.To.String()}
129
		}
130
	case *ast.CommodityDirective:
131
		if spanContains(content, e.CommoditySpan, cursor) {
132
			return &hoverElement{kind: hoverCommodity, span: e.CommoditySpan, name: e.Commodity}
133
		}
134
	case *ast.PayeeDirective:
135
		if e.Name != "" && spanContains(content, e.NameSpan, cursor) {
136
			return &hoverElement{kind: hoverPayee, span: e.NameSpan, name: e.Name}
137
		}
138
	case *ast.TagDirective:
139
		if e.Name != "" {
140
			if span, ok := tagDirectiveSpan(content, e); ok && spanContains(content, span, cursor) {
141
				return &hoverElement{kind: hoverTagKey, span: span, tagKey: e.Name}
142
			}
143
		}
144
	}
145
	return nil
146
}
147
148
func hoverInPostings(content string, postings []ast.Posting, cursor int) *hoverElement {
149
	for _, p := range postings {
150
		if spanContains(content, p.Account.Span, cursor) {
151
			return &hoverElement{kind: hoverAccount, span: p.Account.Span, name: p.Account.String()}
152
		}
153
		if p.Amount != nil {
154
			if spanContains(content, p.Amount.Span, cursor) {
155
				return &hoverElement{kind: hoverAmount, span: p.Amount.Span, amount: p.Amount, cost: p.Cost}
156
			}
157
			if ref := commodityRef(content, p.Amount, cursor); ref != nil {
158
				return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name}
159
			}
160
		}
161
		if p.Cost != nil {
162
			if ref := commodityRef(content, &p.Cost.Amount, cursor); ref != nil {
163
				return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name}
164
			}
165
		}
166
		if p.Balance != nil {
167
			if ref := commodityRef(content, &p.Balance.Amount, cursor); ref != nil {
168
				return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name}
169
			}
170
			if p.Balance.Cost != nil {
171
				if ref := commodityRef(content, &p.Balance.Cost.Amount, cursor); ref != nil {
172
					return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name}
173
				}
174
			}
175
		}
176
		if el := hoverTagInComment(content, p.Comment, cursor); el != nil {
177
			return el
178
		}
179
		for i := range p.Comments {
180
			if el := hoverTagInComment(content, &p.Comments[i], cursor); el != nil {
181
				return el
182
			}
183
		}
184
	}
185
	return nil
186
}
187
188
func hoverTagInComment(content string, c *ast.Comment, cursor int) *hoverElement {
189
	if c == nil {
190
		return nil
191
	}
192
	for i := range c.Tags {
193
		t := &c.Tags[i]
194
		if !spanContains(content, t.Span, cursor) {
195
			continue
196
		}
197
		keySpan := tagKeySpan(content, t)
198
		if spanContains(content, keySpan, cursor) {
199
			return &hoverElement{kind: hoverTagKey, span: keySpan, tagKey: t.Key}
200
		}
201
		return &hoverElement{kind: hoverTagValue, span: tagValueSpan(content, t), tagKey: t.Key, tagValue: t.Value}
202
	}
203
	return nil
204
}
205
206
func buildHoverContent(an *analyzer.Analysis, content string, el *hoverElement) string {
207
	switch el.kind {
208
	case hoverAccount:
209
		return buildAccountHover(an, el.name)
210
	case hoverCommodity:
211
		return buildCommodityHover(an, el.name)
212
	case hoverPayee:
213
		return buildPayeeHover(an, el.name)
214
	case hoverTagKey:
215
		return buildTagHover(an, el.tagKey)
216
	case hoverTagValue:
217
		return buildTagValueHover(an, el.tagKey, el.tagValue)
218
	case hoverAmount:
219
		return buildAmountHover(content, el.amount, el.cost)
220
	case hoverDate:
221
		return buildDateHover(an, el.tx)
222
	default:
223
		return ""
224
	}
225
}
226
227
func buildAccountHover(an *analyzer.Analysis, name string) string {
228
	if canon, ok := an.AccountAliases[name]; ok {
229
		name = canon
230
	}
231
	var sb strings.Builder
232
	fmt.Fprintf(&sb, "**Account:** `%s`", name)
233
	if info := an.Accounts[name]; info != nil {
234
		fmt.Fprintf(&sb, "\n**Postings:** %d", info.UsedCount)
235
		writeDateSection(&sb, "**Last used:**", info.LastUsed)
236
	}
237
	return sb.String()
238
}
239
240
func buildCommodityHover(an *analyzer.Analysis, symbol string) string {
241
	var sb strings.Builder
242
	fmt.Fprintf(&sb, "**Commodity:** `%s`", symbol)
243
	if info := an.Commodities[symbol]; info != nil {
244
		fmt.Fprintf(&sb, "\n**Usage:** %d", info.UsedCount)
245
		writeDateSection(&sb, "**Last used:**", info.LastUsed)
246
	}
247
	return sb.String()
248
}
249
250
func buildPayeeHover(an *analyzer.Analysis, name string) string {
251
	var sb strings.Builder
252
	fmt.Fprintf(&sb, "**Payee:** %s", name)
253
	if info := an.Payees[name]; info != nil {
254
		fmt.Fprintf(&sb, "\n**Transactions:** %d", info.UsedCount)
255
	}
256
	return sb.String()
257
}
258
259
func buildTagHover(an *analyzer.Analysis, key string) string {
260
	var sb strings.Builder
261
	fmt.Fprintf(&sb, "**Tag:** `%s`", key)
262
	if info := an.Tags[key]; info != nil {
263
		fmt.Fprintf(&sb, "\n**Usage:** %d", info.UsedCount)
264
		if len(info.Values) > 0 {
265
			sb.WriteString("\n**Values:**\n")
266
			for i, v := range info.Values {
267
				if i > 0 {
268
					sb.WriteByte('\n')
269
				}
270
				if v == "" {
271
					sb.WriteString("- *(empty)*")
272
				} else {
273
					fmt.Fprintf(&sb, "- `%s`", v)
274
				}
275
			}
276
		}
277
	}
278
	return sb.String()
279
}
280
281
func buildTagValueHover(an *analyzer.Analysis, key, value string) string {
282
	var sb strings.Builder
283
	fmt.Fprintf(&sb, "**Tag:** `%s`", key)
284
	if value == "" {
285
		sb.WriteString("\n**Value:** *(empty)*")
286
	} else {
287
		fmt.Fprintf(&sb, "\n**Value:** `%s`", value)
288
	}
289
	if info := an.Tags[key]; info != nil {
290
		count := 0
291
		for _, u := range info.Usage {
292
			if u.Tag.Value == value {
293
				count++
294
			}
295
		}
296
		fmt.Fprintf(&sb, "\n**Usage:** %d", count)
297
	}
298
	return sb.String()
299
}
300
301
func buildAmountHover(content string, am *ast.Amount, cost *ast.Cost) string {
302
	amountText := strings.TrimRight(content[am.Span.Start.Offset:am.Span.End.Offset], " \t")
303
	var sb strings.Builder
304
	fmt.Fprintf(&sb, "**Amount:** %s", amountText)
305
	if cost != nil {
306
		costText := strings.TrimRight(content[cost.Amount.Span.Start.Offset:cost.Amount.Span.End.Offset], " \t")
307
		label, marker := "**Unit cost:**", "@"
308
		if cost.IsTotal {
309
			label, marker = "**Total cost:**", "@@"
310
		}
311
		fmt.Fprintf(&sb, "\n\n%s %s %s", label, marker, costText)
312
	}
313
	return sb.String()
314
}
315
316
func buildDateHover(an *analyzer.Analysis, tx *ast.Transaction) string {
317
	var sb strings.Builder
318
	fmt.Fprintf(&sb, "**Date:** %04d-%02d-%02d", tx.Date.Year, tx.Date.Month, tx.Date.Day)
319
	if tx.Payee != "" {
320
		fmt.Fprintf(&sb, "\n\n**Payee:** %s", tx.Payee)
321
	}
322
	fmt.Fprintf(&sb, "\n\n**Transactions:** %d", an.CountTransactionsOnDate(tx.Date))
323
	fmt.Fprintf(&sb, "\n\n**Postings:** %d", len(tx.Postings))
324
	return sb.String()
325
}
326
327
func writeDateSection(sb *strings.Builder, label string, d ast.Date) {
328
	if d.Year == 0 {
329
		return
330
	}
331
	fmt.Fprintf(sb, "\n%s %04d-%02d-%02d", label, d.Year, d.Month, d.Day)
332
}
333
334
func tagValueSpan(content string, t *ast.Tag) token.Span {
335
	start := tagKeySpan(content, t).End.Offset
336
	if start < t.Span.End.Offset && content[start] == ':' {
337
		start++
338
	}
339
	end := t.Span.End.Offset
340
	for end > start && (content[end-1] == ' ' || content[end-1] == '\t') {
341
		end--
342
	}
343
	return token.Span{File: t.Span.File, Start: token.Pos{Offset: start}, End: token.Pos{Offset: end}}
344
}