all repos

clerk @ 64f0a9da04eeaff2be4bdd70cb97c3da16951bab

missing tooling for ledger/hledger

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

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