all repos

clerk @ b082668

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: semantic highlights, 2 months ago
1
package lsp
2
3
import (
4
	"context"
5
	"slices"
6
7
	"go.lsp.dev/protocol"
8
	"go.lsp.dev/uri"
9
10
	"olexsmir.xyz/clerk/internal/lsp/lsputil"
11
	"olexsmir.xyz/clerk/journal/ast"
12
	"olexsmir.xyz/clerk/journal/lexer"
13
	"olexsmir.xyz/clerk/journal/token"
14
)
15
16
func (s *server) SemanticTokensFull(ctx context.Context, params *protocol.SemanticTokensParams) (*protocol.SemanticTokens, error) {
17
	tokens, ok := s.tokensForDoc(params.TextDocument.URI)
18
	if !ok {
19
		return &protocol.SemanticTokens{}, nil
20
	}
21
	return &protocol.SemanticTokens{Data: encodeSemTokens(tokens)}, nil
22
}
23
24
func (s *server) SemanticTokensRange(ctx context.Context, params *protocol.SemanticTokensRangeParams) (*protocol.SemanticTokens, error) {
25
	tokens, ok := s.tokensForDoc(params.TextDocument.URI)
26
	if !ok {
27
		return &protocol.SemanticTokens{}, nil
28
	}
29
	start := int(params.Range.Start.Line)
30
	end := int(params.Range.End.Line)
31
	var filtered []semanticToken
32
	for _, t := range tokens {
33
		if t.line >= uint32(start) && t.line <= uint32(end) {
34
			filtered = append(filtered, t)
35
		}
36
	}
37
	return &protocol.SemanticTokens{Data: encodeSemTokens(filtered)}, nil
38
}
39
40
func (s *server) tokensForDoc(doc uri.URI) ([]semanticToken, bool) {
41
	state, ok := s.getDocState(doc)
42
	if !ok {
43
		return nil, false
44
	}
45
	return tokenizeForSemantics(state.text, state.journal), true
46
}
47
48
// Implementation
49
50
const (
51
	SemanticDirective = iota
52
	SemanticDate
53
	SemanticAccount
54
	SemanticCommodity
55
	SemanticAmount
56
	SemanticStatus
57
	SemanticComment
58
	SemString
59
	SemOperator
60
	SemProperty
61
62
	semTypeCount // used to ensure we dont go out of bound
63
)
64
65
var tokenTypeStrings = []string{
66
	string(protocol.SemanticTokenTypesKeyword),   // directive
67
	string(protocol.SemanticTokenTypesClass),     // date
68
	string(protocol.SemanticTokenTypesNamespace), // account
69
	string(protocol.SemanticTokenTypesType),      // commodity
70
	string(protocol.SemanticTokenTypesNumber),    // amount
71
	string(protocol.SemanticTokenTypesOperator),  // status
72
	string(protocol.SemanticTokenTypesComment),   // comment
73
	string(protocol.SemanticTokenTypesString),    // string
74
	string(protocol.SemanticTokenTypesOperator),  // operator
75
	string(protocol.SemanticTokenTypesProperty),  // property
76
}
77
78
const (
79
	modifierAbstract = 1 << 0 // virtual account
80
	modifierNegative = 1 << 1 // negative amount
81
)
82
83
var modifierStrings = []string{
84
	"abstract", // bit 0
85
	"negative", // bit 1
86
}
87
88
func getSemanticTokensLegend() protocol.SemanticTokensLegend {
89
	return protocol.SemanticTokensLegend{
90
		TokenTypes:     tokenTypeStrings,
91
		TokenModifiers: modifierStrings,
92
	}
93
}
94
95
type semanticToken struct {
96
	line      uint32 // 0-based
97
	col       uint32 // 0-based UTF-16
98
	length    uint32 // UTF-16 code units
99
	tokenType uint32
100
	modifiers uint32
101
}
102
103
func tokenizeForSemantics(content string, j *ast.Journal) []semanticToken {
104
	if j == nil || len(j.Errors) > 0 {
105
		return semLexerFallback(content)
106
	}
107
	return visitEntries(content, j)
108
}
109
110
// visitEntries walks the AST and emits semantic tokens.
111
func visitEntries(content string, j *ast.Journal) []semanticToken {
112
	var out []semanticToken
113
	emit := func(s token.Span, tokType uint32, mods uint32) {
114
		if s.Start.Offset >= s.End.Offset {
115
			return
116
		}
117
		out = append(out, semanticTokenAt(content, s, tokType, mods))
118
	}
119
	for _, e := range j.Entries {
120
		visitEntry(content, e, emit)
121
	}
122
	return out
123
}
124
125
// semanticTokenAt converts a source span into a semantic token.
126
func semanticTokenAt(content string, s token.Span, tokType, mods uint32) semanticToken {
127
	line, col := lsputil.LineCol(content, s.Start.Offset)
128
	length := lsputil.Utf16Len(content, s.Start.Offset, s.End.Offset)
129
	return semanticToken{
130
		line:      uint32(line),
131
		col:       uint32(col),
132
		length:    uint32(length),
133
		tokenType: tokType,
134
		modifiers: mods,
135
	}
136
}
137
138
func visitEntry(content string, e ast.Entry, emit semEmitFn) {
139
	switch e := e.(type) {
140
	case *ast.Transaction:
141
		visitTransaction(content, e, emit)
142
	case *ast.PeriodicTransaction:
143
		visitPeriodicTransaction(content, e, emit)
144
	case *ast.AutomatedTransaction:
145
		visitAutomatedTransaction(content, e, emit)
146
	case *ast.AccountDirective:
147
		emit(directiveKeyword(e.Span, "account"), SemanticDirective, 0)
148
		emit(e.Account.Span, SemanticAccount, 0)
149
		if e.Comment != nil {
150
			emit(e.Comment.Span, SemanticComment, 0)
151
		}
152
	case *ast.CommodityDirective:
153
		emit(directiveKeyword(e.Span, "commodity"), SemanticDirective, 0)
154
		if e.Format.Span.End.Offset > 0 {
155
			semEmitAmount(content, &e.Format, emit)
156
		} else if e.CommoditySpan.Start.Offset > 0 && e.CommoditySpan.End.Offset > 0 {
157
			emit(e.CommoditySpan, SemanticCommodity, 0)
158
		}
159
		if e.Comment != nil {
160
			emit(e.Comment.Span, SemanticComment, 0)
161
		}
162
	case *ast.IncludeDirective:
163
		emitDirective(content, e.Span, len("include"), SemString, e.Comment, emit)
164
	case *ast.PayeeDirective:
165
		emitDirective(content, e.Span, len("payee"), SemProperty, e.Comment, emit)
166
	case *ast.TagDirective:
167
		emitDirective(content, e.Span, len("tag"), SemProperty, e.Comment, emit)
168
	case *ast.AliasDirective:
169
		emit(directiveKeyword(e.Span, "alias"), SemanticDirective, 0)
170
		emit(e.From.Span, SemanticAccount, 0)
171
		if op, ok := betweenSpan(content, e.Span.Start.File, e.From.Span.End.Offset, e.To.Span.Start.Offset); ok {
172
			emit(op, SemOperator, 0)
173
		}
174
		emit(e.To.Span, SemanticAccount, 0)
175
		if e.Comment != nil {
176
			emit(e.Comment.Span, SemanticComment, 0)
177
		}
178
	case *ast.YearDirective:
179
		kwLen := len("year")
180
		if content[e.Span.Start.Offset] == 'Y' {
181
			kwLen = 1
182
		}
183
		emitDirective(content, e.Span, kwLen, SemProperty, e.Comment, emit)
184
	case *ast.DecimalMarkDirective:
185
		emitDirective(content, e.Span, len("decimal-mark"), SemProperty, e.Comment, emit)
186
	case *ast.DefaultCommodityDirective:
187
		emit(directiveKeyword(e.Span, "D"), SemanticDirective, 0)
188
		semEmitAmount(content, &e.Amount, emit)
189
		if e.Comment != nil {
190
			emit(e.Comment.Span, SemanticComment, 0)
191
		}
192
	case *ast.MarketPriceDirective:
193
		emit(directiveKeyword(e.Span, "P"), SemanticDirective, 0)
194
		emit(e.DateTime.Date.Span, SemanticDate, 0)
195
		if e.DateTime.Time != nil {
196
			emit(e.DateTime.Time.Span, SemanticDate, 0)
197
		}
198
		// commodity: text between the date (or time) and the amount
199
		commStart := e.DateTime.Date.Span.End.Offset
200
		if e.DateTime.Time != nil {
201
			commStart = e.DateTime.Time.Span.End.Offset
202
		}
203
		if comm, ok := betweenSpan(content, e.Span.Start.File, commStart, e.Amount.Span.Start.Offset); ok {
204
			emit(comm, SemanticCommodity, 0)
205
		}
206
		semEmitAmount(content, &e.Amount, emit)
207
		if e.Comment != nil {
208
			emit(e.Comment.Span, SemanticComment, 0)
209
		}
210
	case *ast.ConversionDirective:
211
		emit(directiveKeyword(e.Span, "C"), SemanticDirective, 0)
212
		semEmitAmount(content, &e.From, emit)
213
		// = operator: text between the two amounts
214
		if op, ok := betweenSpan(content, e.Span.Start.File, e.From.Span.End.Offset, e.To.Span.Start.Offset); ok {
215
			emit(op, SemOperator, 0)
216
		}
217
		semEmitAmount(content, &e.To, emit)
218
		if e.Comment != nil {
219
			emit(e.Comment.Span, SemanticComment, 0)
220
		}
221
	case *ast.Comment:
222
		emit(e.Span, SemanticComment, 0)
223
	case *ast.CommentBlockDirective:
224
		emit(e.Span, SemanticComment, 0)
225
	case *ast.IgnoredDirective:
226
		emitDirective(content, e.Span, len("N"), SemProperty, e.Comment, emit)
227
	case *ast.ApplyDirective:
228
		emitDirective(content, e.Span, len("apply"), SemProperty, e.Comment, emit)
229
	case *ast.EndDirective:
230
		emitDirective(content, e.Span, len("end"), SemProperty, e.Comment, emit)
231
	case *ast.BlankLine:
232
	}
233
}
234
235
func visitTransaction(content string, t *ast.Transaction, emit semEmitFn) {
236
	emit(t.Date.Span, SemanticDate, 0)
237
	if t.SecondDate != nil {
238
		emit(t.SecondDate.Span, SemanticDate, 0)
239
	}
240
	if t.Status.Value != ast.StatusNone {
241
		emit(t.Status.Span, SemanticStatus, 0)
242
	}
243
	if t.Code != nil {
244
		emit(t.Code.Span, SemString, 0)
245
	}
246
	if t.Payee != nil {
247
		emit(t.Payee.Span, SemProperty, 0)
248
	}
249
	if t.Note != nil {
250
		emit(t.Note.Span, SemProperty, 0)
251
	}
252
	if t.Comment != nil {
253
		emit(t.Comment.Span, SemanticComment, 0)
254
	}
255
	for i := range t.HeaderComments {
256
		emit(t.HeaderComments[i].Span, SemanticComment, 0)
257
	}
258
	for _, p := range t.Postings {
259
		visitPosting(content, p, emit)
260
	}
261
}
262
263
func visitPeriodicTransaction(content string, pt *ast.PeriodicTransaction, emit semEmitFn) {
264
	// ~ operator is at the start of the period span
265
	emit(offsetSpan(pt.Span.Start.File, pt.Span.Start.Offset, pt.Span.Start.Offset+1), SemOperator, 0)
266
267
	// The period span covers the whole expr, including any "from ... to ..." dates
268
	if pt.Period.Span.End.Offset > pt.Period.Span.Start.Offset {
269
		var dates []*ast.Date
270
		if pt.Period.From != nil {
271
			dates = append(dates, pt.Period.From)
272
		}
273
		if pt.Period.To != nil {
274
			dates = append(dates, pt.Period.To)
275
		}
276
		pos := pt.Period.Span.Start.Offset
277
		for _, d := range dates {
278
			if d.Span.Start.Offset > pos {
279
				emit(offsetSpan(pt.Period.Span.Start.File, pos, d.Span.Start.Offset), SemProperty, 0)
280
			}
281
			emit(d.Span, SemanticDate, 0)
282
			pos = d.Span.End.Offset
283
		}
284
		if pos < pt.Period.Span.End.Offset {
285
			emit(offsetSpan(pt.Period.Span.Start.File, pos, pt.Period.Span.End.Offset), SemProperty, 0)
286
		}
287
	}
288
	if pt.Description != nil {
289
		emit(pt.Description.Span, SemProperty, 0)
290
	}
291
	if pt.Comment != nil {
292
		emit(pt.Comment.Span, SemanticComment, 0)
293
	}
294
	for i := range pt.HeaderComments {
295
		emit(pt.HeaderComments[i].Span, SemanticComment, 0)
296
	}
297
	for _, p := range pt.Postings {
298
		visitPosting(content, p, emit)
299
	}
300
}
301
302
func visitAutomatedTransaction(content string, at *ast.AutomatedTransaction, emit semEmitFn) {
303
	// = operator is at the start of the expression span
304
	emit(offsetSpan(at.Span.Start.File, at.Span.Start.Offset, at.Span.Start.Offset+1), SemOperator, 0)
305
306
	if at.Expr.Value != "" {
307
		emit(at.Expr.Span, SemString, 0)
308
	}
309
	if at.Comment != nil {
310
		emit(at.Comment.Span, SemanticComment, 0)
311
	}
312
	for i := range at.HeaderComments {
313
		emit(at.HeaderComments[i].Span, SemanticComment, 0)
314
	}
315
	for _, p := range at.Postings {
316
		visitPosting(content, p, emit)
317
	}
318
}
319
320
func visitPosting(content string, p *ast.Posting, emit semEmitFn) {
321
	if p.Status.Value != ast.StatusNone {
322
		emit(p.Status.Span, SemanticStatus, 0)
323
	}
324
325
	// virtual brackets
326
	if p.Type == ast.PostingVirtualUnbalanced || p.Type == ast.PostingVirtualBalanced {
327
		// opening bracket
328
		for off := p.Span.Start.Offset; off < p.Account.Span.Start.Offset && off < p.Span.End.Offset; off++ {
329
			if content[off] == '(' || content[off] == '[' {
330
				brSpan := token.Span{Start: offsetPos(p.Span.Start.File, off), End: offsetPos(p.Span.Start.File, off+1)}
331
				emit(brSpan, SemOperator, modifierAbstract)
332
				break
333
			}
334
		}
335
		// closing bracket
336
		for off := p.Account.Span.End.Offset; off < p.Span.End.Offset; off++ {
337
			if content[off] == ')' || content[off] == ']' {
338
				brSpan := token.Span{Start: offsetPos(p.Span.Start.File, off), End: offsetPos(p.Span.Start.File, off+1)}
339
				emit(brSpan, SemOperator, modifierAbstract)
340
				break
341
			}
342
		}
343
	}
344
345
	emit(p.Account.Span, SemanticAccount, 0)
346
347
	if p.Amount != nil {
348
		semEmitAmount(content, p.Amount, emit)
349
	}
350
	if p.Cost != nil {
351
		semEmitCost(content, p.Cost, emit)
352
	}
353
	if p.Balance != nil {
354
		semEmitBalanceAssertion(content, p.Balance, emit)
355
	}
356
	if p.Comment != nil {
357
		emit(p.Comment.Span, SemanticComment, 0)
358
	}
359
	for i := range p.Comments {
360
		emit(p.Comments[i].Span, SemanticComment, 0)
361
	}
362
}
363
364
// directiveKeyword returns the span of the leading keyword on a directive line.
365
func directiveKeyword(e token.Span, kw string) token.Span {
366
	return token.Span{Start: e.Start, End: offsetPos(e.Start.File, e.Start.Offset+len(kw))}
367
}
368
369
// directiveValue returns the trimmed span of the text after the keyword end
370
// offset, up to the inline comment or the end of the line.
371
func directiveValue(content string, e token.Span, comment *ast.Comment, kwEnd int) (token.Span, bool) {
372
	end := e.End.Offset
373
	if comment != nil {
374
		end = comment.Span.Start.Offset
375
	}
376
	return betweenSpan(content, e.Start.File, kwEnd, end)
377
}
378
379
func semEmitAmount(content string, a *ast.Amount, emit semEmitFn) {
380
	if a == nil {
381
		return
382
	}
383
	hasCommodity := a.CommoditySpan.Start.Offset > 0 && a.CommoditySpan.End.Offset > 0
384
	if hasCommodity && a.CommodityPos == ast.CommodityBefore {
385
		emit(a.CommoditySpan, SemanticCommodity, 0)
386
		semEmitQuantity(content, a, emit)
387
		return
388
	}
389
	semEmitQuantity(content, a, emit)
390
	if hasCommodity {
391
		emit(a.CommoditySpan, SemanticCommodity, 0)
392
	}
393
}
394
395
func semEmitQuantity(content string, a *ast.Amount, emit semEmitFn) {
396
	qStart, qEnd := quantitySpan(content, a)
397
	if qEnd <= qStart {
398
		return
399
	}
400
	mods := uint32(0)
401
	if a.IsNegative {
402
		mods |= modifierNegative
403
	}
404
	emit(offsetSpan(a.Span.Start.File, qStart, qEnd), SemanticAmount, mods)
405
}
406
407
func quantitySpan(content string, a *ast.Amount) (int, int) {
408
	start, end := a.Span.Start.Offset, a.Span.End.Offset
409
	switch {
410
	case a.Commodity == "":
411
		// bare quantity
412
	case a.CommodityPos == ast.CommodityBefore:
413
		// "$50.00" or "$   50.00": quantity follows the commodity span
414
		start = a.CommoditySpan.End.Offset
415
	default: // CommodityAfter
416
		// "50.00 USD" or "50.00     USD": quantity precedes the commodity
417
		end = a.CommoditySpan.Start.Offset
418
	}
419
	for start < end && (content[start] == ' ' || content[start] == '\t') {
420
		start++
421
	}
422
	for end > start && (content[end-1] == ' ' || content[end-1] == '\t') {
423
		end--
424
	}
425
	return start, end
426
}
427
428
type semEmitFn func(token.Span, uint32, uint32)
429
430
func emitDirective(content string, e token.Span, kwLen int, valType uint32, comment *ast.Comment, emit semEmitFn) {
431
	kwEnd := e.Start.Offset + kwLen
432
	emit(token.Span{Start: e.Start, End: offsetPos(e.Start.File, kwEnd)}, SemanticDirective, 0)
433
	if v, ok := directiveValue(content, e, comment, kwEnd); ok {
434
		emit(v, valType, 0)
435
	}
436
	if comment != nil {
437
		emit(comment.Span, SemanticComment, 0)
438
	}
439
}
440
441
func semEmitCost(content string, c *ast.Cost, emit semEmitFn) {
442
	if c.IsTotal {
443
		emit(token.Span{Start: c.Span.Start, End: offsetPos(c.Span.Start.File, c.Span.Start.Offset+2)}, SemOperator, 0)
444
	} else {
445
		emit(token.Span{Start: c.Span.Start, End: offsetPos(c.Span.Start.File, c.Span.Start.Offset+1)}, SemOperator, 0)
446
	}
447
	semEmitAmount(content, &c.Amount, emit)
448
}
449
450
func semEmitBalanceAssertion(content string, ba *ast.BalanceAssertion, emit semEmitFn) {
451
	// The operator is the run of '=', ':', '*' chars from the span start.
452
	// The ':' of ':=' precedes the '=' token, so back up one offset.
453
	opStart := ba.Span.Start.Offset
454
	if ba.IsAssignment && opStart > 0 {
455
		opStart--
456
	}
457
	opEnd := opStart
458
	for opEnd < ba.Span.End.Offset && (content[opEnd] == '=' || content[opEnd] == ':' || content[opEnd] == '*') {
459
		opEnd++
460
	}
461
	emit(token.Span{Start: offsetPos(ba.Span.Start.File, opStart), End: offsetPos(ba.Span.Start.File, opEnd)}, SemOperator, 0)
462
	semEmitAmount(content, &ba.Amount, emit)
463
	if ba.Cost != nil {
464
		semEmitCost(content, ba.Cost, emit)
465
	}
466
}
467
468
// semLexerFallback produces semantic tokens using only the lexer (for unparseable documents).
469
func semLexerFallback(content string) []semanticToken {
470
	l := lexer.New("", []byte(content))
471
	var out []semanticToken
472
473
	var commentStart, commentEnd int // 0 = not inside a comment line
474
	take := func(span token.Span, tokType uint32, mods uint32) {
475
		out = append(out, semanticTokenAt(content, span, tokType, mods))
476
	}
477
	for {
478
		tok := l.Next()
479
		if tok.Type == token.EOF {
480
			if commentStart > 0 {
481
				take(token.Span{Start: offsetPos("", commentStart), End: offsetPos("", commentEnd)}, SemanticComment, 0)
482
			}
483
			break
484
		}
485
		if tok.Type == token.NEWLINE {
486
			if commentStart > 0 {
487
				take(token.Span{Start: offsetPos("", commentStart), End: offsetPos("", commentEnd)}, SemanticComment, 0)
488
				commentStart, commentEnd = 0, 0
489
			}
490
			continue
491
		}
492
		if tok.Type == token.WHITESPACE || tok.Type == token.INDENT {
493
			continue
494
		}
495
		tokType := uint32(SemString)
496
		if commentStart > 0 {
497
			tokType = SemanticComment
498
			if tok.Span.End.Offset > commentEnd {
499
				commentEnd = tok.Span.End.Offset
500
			}
501
			continue
502
		}
503
		switch tok.Type {
504
		case token.SEMICOLON, token.HASH, token.PERCENT:
505
			tokType = SemanticComment
506
			commentStart = tok.Span.Start.Offset
507
			commentEnd = tok.Span.End.Offset
508
			continue
509
		case token.STAR:
510
			tokType = SemanticComment // * at col 0 is comment marker
511
			commentStart = tok.Span.Start.Offset
512
			commentEnd = tok.Span.End.Offset
513
			continue
514
		case token.ACCOUNT, token.COMMODITY, token.INCLUDE, token.ALIAS,
515
			token.PAYEE, token.TAG, token.APPLY, token.END, token.COMMENTKW,
516
			token.YEAR, token.DECIMALMARK, token.D, token.P, token.N, token.C:
517
			tokType = SemanticDirective
518
		case token.DATE:
519
			tokType = SemanticDate
520
		case token.INT, token.DECIMAL:
521
			tokType = SemanticAmount
522
		case token.COMMODITYMARK:
523
			tokType = SemanticCommodity
524
		case token.BANG:
525
			tokType = SemanticStatus
526
		case token.AT, token.ATAT, token.EQ, token.EQEQ, token.EQEQEQ, token.EQSTAR:
527
			tokType = SemOperator
528
		}
529
		take(tok.Span, tokType, 0)
530
	}
531
	return out
532
}
533
534
func encodeSemTokens(tokens []semanticToken) []uint32 {
535
	if len(tokens) == 0 {
536
		return nil
537
	}
538
	slices.SortFunc(tokens, func(a, b semanticToken) int {
539
		if a.line != b.line {
540
			return int(a.line) - int(b.line)
541
		}
542
		return int(a.col) - int(b.col)
543
	})
544
	data := make([]uint32, 0, len(tokens)*5)
545
	var prevLine, prevCol uint32
546
	for _, t := range tokens {
547
		var deltaLine, deltaCol uint32
548
		if t.line == prevLine {
549
			deltaLine = 0
550
			deltaCol = t.col - prevCol
551
		} else {
552
			deltaLine = t.line - prevLine
553
			deltaCol = t.col
554
		}
555
		data = append(data, deltaLine, deltaCol, t.length, t.tokenType, t.modifiers)
556
		prevLine = t.line
557
		prevCol = t.col
558
	}
559
	return data
560
}
561
562
// betweenSpan returns the span of the text between two offsets, trimmed of surrounding whitespace.
563
func betweenSpan(content, file string, start, end int) (token.Span, bool) {
564
	for start < end && (content[start] == ' ' || content[start] == '\t') {
565
		start++
566
	}
567
	for end > start && (content[end-1] == ' ' || content[end-1] == '\t' || content[end-1] == '\n' || content[end-1] == '\r') {
568
		end--
569
	}
570
	if end <= start {
571
		return token.Span{}, false
572
	}
573
	return token.Span{Start: offsetPos(file, start), End: offsetPos(file, end)}, true
574
}
575
576
func offsetPos(file string, offset int) token.Pos { return token.Pos{File: file, Offset: offset} }
577
func offsetSpan(file string, start, end int) token.Span {
578
	return token.Span{Start: offsetPos(file, start), End: offsetPos(file, end)}
579
}