all repos

clerk @ ed6cfe6

missing tooling for ledger/hledger
11 files changed, 646 insertions(+), 59 deletions(-)
lsp: textdocument/selectionRange

Return, per position, the innermost AST span containing the cursor with
parent ranges chained out to the whole document. Selectable levels:
account segments, commodity and quantity inside amounts, tag keys in
comments, period dates, and transaction header parts. Golden test in
testdata/selection-range.txtar drives the fixture.

lsputil gets ASCII fast paths in the UTF-16 converters and LineIndex.Offset.
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-09-02 20:12:41 +0300
Authored at: 2026-09-02 19:22:26 +0300
Change ID: zzwlvkpsznwqsozoqlvxzuwyrwqyqoxq
Parent: e6f9851
M internal/lsp/lsputil/lineindex.go
···
        61
        61
         	for lineEnd > lineStart && (l.content[lineEnd-1] == '\n' || l.content[lineEnd-1] == '\r') {

      
        62
        62
         		lineEnd--

      
        63
        63
         	}

      
        
        64
        +	seg := l.content[lineStart:lineEnd]

      
        
        65
        +	ascii := true

      
        
        66
        +	for i := range seg {

      
        
        67
        +		if seg[i] >= utf8.RuneSelf {

      
        
        68
        +			ascii = false

      
        
        69
        +			break

      
        
        70
        +		}

      
        
        71
        +	}

      
        
        72
        +	if ascii {

      
        
        73
        +		if col >= len(seg) {

      
        
        74
        +			return lineEnd

      
        
        75
        +		}

      
        
        76
        +		return lineStart + col

      
        
        77
        +	}

      
        64
        78
         	off := lineStart

      
        65
        79
         	units := 0

      
        66
        80
         	for off < lineEnd && units < col {

      
M internal/lsp/lsputil/utf16.go
···
        7
        7
         )

      
        8
        8
         

      
        9
        9
         // Utf16Col returns the UTF-16 code unit column (0-based) for a byte offset

      
        10
        
        -// within the given line content (without newline).

      
        
        10
        +// within the given line content (without newline). Pure-ASCII prefixes score

      
        
        11
        +// one unit per byte without decoding.

      
        11
        12
         func Utf16Col(line string, byteOffset int) int {

      
        12
        13
         	if byteOffset <= 0 {

      
        13
        14
         		return 0

      ···
        15
        16
         	if byteOffset > len(line) {

      
        16
        17
         		byteOffset = len(line)

      
        17
        18
         	}

      
        
        19
        +	asc := line[:byteOffset]

      
        
        20
        +	for i := range asc {

      
        
        21
        +		if asc[i] >= utf8.RuneSelf {

      
        
        22
        +			return utf16ColSlow(line, byteOffset)

      
        
        23
        +		}

      
        
        24
        +	}

      
        
        25
        +	return byteOffset

      
        
        26
        +}

      
        
        27
        +

      
        
        28
        +func utf16ColSlow(line string, byteOffset int) int {

      
        18
        29
         	col := 0

      
        19
        30
         	for i := 0; i < byteOffset; {

      
        20
        31
         		r, size := utf8.DecodeRuneInString(line[i:])

      ···
        29
        40
         

      
        30
        41
         // Utf16ColBytes returns the UTF-16 code unit column of b (without newline).

      
        31
        42
         func Utf16ColBytes(b []byte) int {

      
        
        43
        +	for _, c := range b {

      
        
        44
        +		if c >= utf8.RuneSelf {

      
        
        45
        +			return utf16ColBytesSlow(b)

      
        
        46
        +		}

      
        
        47
        +	}

      
        
        48
        +	return len(b)

      
        
        49
        +}

      
        
        50
        +

      
        
        51
        +func utf16ColBytesSlow(b []byte) int {

      
        32
        52
         	col := 0

      
        33
        53
         	for i := 0; i < len(b); {

      
        34
        54
         		r, size := utf8.DecodeRune(b[i:])

      ···
        52
        72
         	if offset >= end {

      
        53
        73
         		return 0

      
        54
        74
         	}

      
        
        75
        +	seg := content[offset:end]

      
        
        76
        +	for i := range seg {

      
        
        77
        +		if seg[i] >= utf8.RuneSelf {

      
        
        78
        +			return utf16LenSlow(content, offset, end)

      
        
        79
        +		}

      
        
        80
        +	}

      
        
        81
        +	return end - offset

      
        
        82
        +}

      
        
        83
        +

      
        
        84
        +func utf16LenSlow(content string, offset, end int) int {

      
        55
        85
         	n := 0

      
        56
        86
         	for i := offset; i < end; {

      
        57
        87
         		r, size := utf8.DecodeRuneInString(content[i:])

      
M internal/lsp/server.go
···
        36
        36
         	configPath    string

      
        37
        37
         }

      
        38
        38
         

      
        
        39
        +// parsedFileFor returns the parsed file for path within the analysis, or nil.

      
        
        40
        +func parsedFileFor(an *analyzer.Analysis, path string) *journal.ParsedFile {

      
        
        41
        +	for _, pf := range an.Files {

      
        
        42
        +		if pf.Path == path {

      
        
        43
        +			return pf

      
        
        44
        +		}

      
        
        45
        +	}

      
        
        46
        +	return nil

      
        
        47
        +}

      
        
        48
        +

      
        39
        49
         // analysisFor returns the cached analysis for an open doc, rebuilds when the doc or a file it inclues changed.

      
        40
        50
         func (s *server) analysisFor(u uri.URI) *analyzer.Analysis {

      
        41
        51
         	s.mu.RLock()

      ···
        102
        112
         			ReferencesProvider:         protocol.Boolean(true),

      
        103
        113
         			WorkspaceSymbolProvider:    protocol.Boolean(true),

      
        104
        114
         			DocumentSymbolProvider:     protocol.Boolean(true),

      
        
        115
        +			FoldingRangeProvider:       protocol.Boolean(true),

      
        
        116
        +			SelectionRangeProvider:     protocol.Boolean(true),

      
        105
        117
         			RenameProvider: &protocol.RenameOptions{

      
        106
        118
         				PrepareProvider: new(true),

      
        107
        119
         			},

      
        108
        120
         			CompletionProvider: &protocol.CompletionOptions{

      
        109
        121
         				TriggerCharacters: []string{":", "@"},

      
        110
        122
         			},

      
        111
        
        -			FoldingRangeProvider: protocol.Boolean(true),

      
        112
        123
         			TextDocumentSync: &protocol.TextDocumentSyncOptions{

      
        113
        124
         				OpenClose: new(true),

      
        114
        125
         				Change:    new(protocol.TextDocumentSyncKindFull),

      
A internal/lsp/testdata/selection-range.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +; file co^mment

      
        
        3
        +; ta^g:file

      
        
        4
        +

      
        
        5
        +account expenses:^food

      
        
        6
        +  alias super^market

      
        
        7
        +commodity ^$

      
        
        8
        +payee Grocery ^Store

      
        
        9
        +payee "Quoted ^Payee"

      
        
        10
        +

      
        
        11
        +2024-^01-15 ^* ^(cafe) Grocery ^Store | lu^nch ; ta^g:v1

      
        
        12
        +    ; header comment tag:^h1

      
        
        13
        +    expenses:^food:cafe  $12.50  ; discount tag:d1

      
        
        14
        +    assets:^cash

      
        
        15
        +    (virtual:un^balanced)  $5

      
        
        16
        +    expenses:food:cafe  ^$12.50  ; discount tag:d1

      
        
        17
        +    expenses:food:cafe  $12.^50  ; discount tag:d1

      
        
        18
        +    expenses:food:cafe  $12.50  ; discount ^tag:d1

      
        
        19
        +    expenses:food  50 EU^R @ 1.2 USD

      
        
        20
        +    expenses:food  50 EUR @ ^1.2 USD

      
        
        21
        +    assets:checking  10 USD = ^20 USD

      
        
        22
        +

      
        
        23
        +2024-01-16 ^Grocery

      
        
        24
        +    expenses:food  50 ^EUR

      
        
        25
        +    ca^sh  $5

      
        
        26
        +

      
        
        27
        +~ monthly  Gro^cery

      
        
        28
        +    expenses:food  10 ^EUR

      
        
        29
        +

      
        
        30
        +~ every ^week from 2024-01-^01 to 2024-0^2-01 ; note

      
        
        31
        +    expenses:food  10 EUR

      
        
        32
        +

      
        
        33
        += ex^pr

      
        
        34
        +    expenses:food  10 EUR

      
        
        35
        +

      
        
        36
        +alias expenses:ca^fe = expenses:food:restaurant

      
        
        37
        +tag na^me

      
        
        38
        +D 1 U^SD

      
        
        39
        +P 2024-01-20 12:^00:00 EUR 1.^2 USD

      
        
        40
        +C 1 EUR = 1.^1 USD

      
        
        41
        +

      
        
        42
        +payee Ка^фе

      
        
        43
        +

      
        
        44
        +^

      
        
        45
        +2024-01-17 t

      
        
        46
        +    assets:cash

      
        
        47
        +^

      
        
        48
        +

      
        
        49
        +-- expect --

      
        
        50
        +0:9 "; file comment" -> <doc>

      
        
        51
        +1:4 "tag" -> "; tag:file" -> <doc>

      
        
        52
        +3:17 "food" -> "expenses:food" -> "account expenses:food\n  alias supermarket" -> <doc>

      
        
        53
        +4:13 "supermarket" -> "account expenses:food\n  alias supermarket" -> <doc>

      
        
        54
        +5:10 "$" -> "commodity $" -> <doc>

      
        
        55
        +6:14 "Grocery Store" -> "payee Grocery Store" -> <doc>

      
        
        56
        +7:14 "\"Quoted Payee\"" -> "payee \"Quoted Payee\"" -> <doc>

      
        
        57
        +9:5 "2024-01-15" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        58
        +9:11 "*" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        59
        +9:13 "(cafe)" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        60
        +9:28 "Grocery Store" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        61
        +9:38 "lunch" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        62
        +9:46 "tag" -> "; tag:v1" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        63
        +10:25 "; header comment tag:h1" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        64
        +11:13 "food" -> "expenses:food:cafe" -> "    expenses:food:cafe  $12.50  ; discount tag:d1" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        65
        +12:11 "cash" -> "assets:cash" -> "    assets:cash" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        66
        +13:15 "unbalanced" -> "virtual:unbalanced" -> "    (virtual:unbalanced)  $5" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        67
        +14:24 "$" -> "$12.50" -> "    expenses:food:cafe  $12.50  ; discount tag:d1" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        68
        +15:28 "12.50" -> "$12.50" -> "    expenses:food:cafe  $12.50  ; discount tag:d1" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        69
        +16:43 "tag" -> "; discount tag:d1" -> "    expenses:food:cafe  $12.50  ; discount tag:d1" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        70
        +17:24 "EUR" -> "50 EUR" -> "    expenses:food  50 EUR @ 1.2 USD" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        71
        +18:28 "@ 1.2 USD" -> "    expenses:food  50 EUR @ 1.2 USD" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        72
        +19:30 "= 20 USD" -> "    assets:checking  10 USD = 20 USD" -> "2024-01-15 * (cafe) Grocery Store | lunch ; tag:v1\n    ; header comment tag:h1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    assets:cash\n    (virtual:unbalanced)  $5\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food:cafe  $12.50  ; discount tag:d1\n    expenses:food  50 EUR @ 1.2 USD\n    expenses:food  50 EUR @ 1.2 USD\n    assets:checking  10 USD = 20 USD" -> <doc>

      
        
        73
        +21:11 "Grocery" -> "2024-01-16 Grocery\n    expenses:food  50 EUR\n    cash  $5" -> <doc>

      
        
        74
        +22:22 "EUR" -> "50 EUR" -> "    expenses:food  50 EUR" -> "2024-01-16 Grocery\n    expenses:food  50 EUR\n    cash  $5" -> <doc>

      
        
        75
        +23:6 "cash" -> "    cash  $5" -> "2024-01-16 Grocery\n    expenses:food  50 EUR\n    cash  $5" -> <doc>

      
        
        76
        +25:14 "Grocery" -> "~ monthly  Grocery\n    expenses:food  10 EUR" -> <doc>

      
        
        77
        +26:22 "EUR" -> "10 EUR" -> "    expenses:food  10 EUR" -> "~ monthly  Grocery\n    expenses:food  10 EUR" -> <doc>

      
        
        78
        +28:8 "every week from 2024-01-01 to 2024-02-01" -> "~ every week from 2024-01-01 to 2024-02-01 ; note\n    expenses:food  10 EUR" -> <doc>

      
        
        79
        +28:26 "2024-01-01" -> "every week from 2024-01-01 to 2024-02-01" -> "~ every week from 2024-01-01 to 2024-02-01 ; note\n    expenses:food  10 EUR" -> <doc>

      
        
        80
        +28:38 "2024-02-01" -> "every week from 2024-01-01 to 2024-02-01" -> "~ every week from 2024-01-01 to 2024-02-01 ; note\n    expenses:food  10 EUR" -> <doc>

      
        
        81
        +31:4 "expr" -> "= expr\n    expenses:food  10 EUR" -> <doc>

      
        
        82
        +34:17 "cafe" -> "expenses:cafe" -> "alias expenses:cafe = expenses:food:restaurant" -> <doc>

      
        
        83
        +35:6 "name" -> "tag name" -> <doc>

      
        
        84
        +36:5 "USD" -> "1 USD" -> "D 1 USD" -> <doc>

      
        
        85
        +37:16 "12:00:00" -> "P 2024-01-20 12:00:00 EUR 1.2 USD" -> <doc>

      
        
        86
        +37:28 "1.2" -> "1.2 USD" -> "P 2024-01-20 12:00:00 EUR 1.2 USD" -> <doc>

      
        
        87
        +38:12 "1.1" -> "1.1 USD" -> "C 1 EUR = 1.1 USD" -> <doc>

      
        
        88
        +40:8 "Кафе" -> "payee Кафе" -> <doc>

      
        
        89
        +42:0 <doc>

      
        
        90
        +45:0 <doc>

      
M internal/lsp/textdocument_definition.go
···
        220
        220
         }

      
        221
        221
         

      
        222
        222
         func entryAt(entries []ast.Entry, cursor int) ast.Entry {

      
        223
        
        -	idx := sort.Search(len(entries), func(i int) bool { return entryStart(entries[i]) > cursor }) - 1

      
        
        223
        +	idx := sort.Search(len(entries), func(i int) bool { return entrySpan(entries[i]).Start.Offset > cursor }) - 1

      
        224
        224
         	if idx < 0 {

      
        225
        225
         		return nil

      
        226
        226
         	}

      
        227
        227
         	return entries[idx]

      
        228
        228
         }

      
        229
        229
         

      
        230
        
        -func entryStart(e ast.Entry) int {

      
        
        230
        +// entrySpan returns the span covering an entry.

      
        
        231
        +func entrySpan(e ast.Entry) token.Span {

      
        231
        232
         	switch e := e.(type) {

      
        232
        233
         	case *ast.BlankLine:

      
        233
        
        -		return e.Span.Start.Offset

      
        
        234
        +		return e.Span

      
        234
        235
         	case *ast.Transaction:

      
        235
        
        -		return e.Span.Start.Offset

      
        
        236
        +		return e.Span

      
        236
        237
         	case *ast.PeriodicTransaction:

      
        237
        
        -		return e.Span.Start.Offset

      
        
        238
        +		return e.Span

      
        238
        239
         	case *ast.AutomatedTransaction:

      
        239
        
        -		return e.Span.Start.Offset

      
        
        240
        +		return e.Span

      
        240
        241
         	case *ast.Comment:

      
        241
        
        -		return e.Span.Start.Offset

      
        
        242
        +		return e.Span

      
        242
        243
         	case *ast.AccountDirective:

      
        243
        
        -		return e.Span.Start.Offset

      
        
        244
        +		return e.Span

      
        244
        245
         	case *ast.CommodityDirective:

      
        245
        
        -		return e.Span.Start.Offset

      
        
        246
        +		return e.Span

      
        246
        247
         	case *ast.PayeeDirective:

      
        247
        
        -		return e.Span.Start.Offset

      
        
        248
        +		return e.Span

      
        248
        249
         	case *ast.TagDirective:

      
        249
        
        -		return e.Span.Start.Offset

      
        
        250
        +		return e.Span

      
        250
        251
         	case *ast.IncludeDirective:

      
        251
        
        -		return e.Span.Start.Offset

      
        
        252
        +		return e.Span

      
        252
        253
         	case *ast.AliasDirective:

      
        253
        
        -		return e.Span.Start.Offset

      
        
        254
        +		return e.Span

      
        254
        255
         	case *ast.YearDirective:

      
        255
        
        -		return e.Span.Start.Offset

      
        
        256
        +		return e.Span

      
        256
        257
         	case *ast.DecimalMarkDirective:

      
        257
        
        -		return e.Span.Start.Offset

      
        
        258
        +		return e.Span

      
        258
        259
         	case *ast.DefaultCommodityDirective:

      
        259
        
        -		return e.Span.Start.Offset

      
        
        260
        +		return e.Span

      
        260
        261
         	case *ast.MarketPriceDirective:

      
        261
        
        -		return e.Span.Start.Offset

      
        
        262
        +		return e.Span

      
        262
        263
         	case *ast.ConversionDirective:

      
        263
        
        -		return e.Span.Start.Offset

      
        
        264
        +		return e.Span

      
        264
        265
         	case *ast.ApplyDirective:

      
        265
        
        -		return e.Span.Start.Offset

      
        
        266
        +		return e.Span

      
        266
        267
         	case *ast.EndDirective:

      
        267
        
        -		return e.Span.Start.Offset

      
        
        268
        +		return e.Span

      
        268
        269
         	case *ast.CommentBlockDirective:

      
        269
        
        -		return e.Span.Start.Offset

      
        
        270
        +		return e.Span

      
        270
        271
         	case *ast.IgnoredDirective:

      
        271
        
        -		return e.Span.Start.Offset

      
        
        272
        +		return e.Span

      
        272
        273
         	}

      
        273
        
        -	return 0

      
        
        274
        +	return token.Span{}

      
        274
        275
         }

      
        275
        276
         

      
        276
        277
         func spanEndClamped(content string, end int) int {

      
M internal/lsp/textdocument_document_symbol.go
···
        14
        14
         	if an == nil {

      
        15
        15
         		return nil, nil

      
        16
        16
         	}

      
        17
        
        -	for _, pf := range an.Files {

      
        18
        
        -		if pf.Path == params.TextDocument.URI.Path() {

      
        19
        
        -			symbols := make([]protocol.DocumentSymbol, 0, len(pf.Ast.Entries)/2)

      
        20
        
        -			for _, entry := range pf.Ast.Entries {

      
        21
        
        -				if symbol, ok := entryDocumentSymbol(entry, pf.Src); ok {

      
        22
        
        -					symbols = append(symbols, symbol)

      
        23
        
        -				}

      
        24
        
        -			}

      
        25
        
        -			return protocol.DocumentSymbolSlice(symbols), nil

      
        
        17
        +	pf := parsedFileFor(an, params.TextDocument.URI.Path())

      
        
        18
        +	if pf == nil {

      
        
        19
        +		return nil, nil

      
        
        20
        +	}

      
        
        21
        +	symbols := make([]protocol.DocumentSymbol, 0, len(pf.Ast.Entries)/2)

      
        
        22
        +	for _, entry := range pf.Ast.Entries {

      
        
        23
        +		if symbol, ok := entryDocumentSymbol(entry, pf.Src); ok {

      
        
        24
        +			symbols = append(symbols, symbol)

      
        26
        25
         		}

      
        27
        26
         	}

      
        28
        
        -	return nil, nil

      
        
        27
        +	return protocol.DocumentSymbolSlice(symbols), nil

      
        29
        28
         }

      
        30
        29
         

      
        31
        30
         func entryDocumentSymbol(e ast.Entry, src []byte) (protocol.DocumentSymbol, bool) {

      
M internal/lsp/textdocument_folding_range.go
···
        16
        16
         	if an == nil {

      
        17
        17
         		return nil, nil

      
        18
        18
         	}

      
        19
        
        -	for _, pf := range an.Files {

      
        20
        
        -		if pf.Path != params.TextDocument.URI.Path() {

      
        21
        
        -			continue

      
        22
        
        -		}

      
        23
        
        -		ranges := foldingRangesFor(pf.Ast.Entries)

      
        24
        
        -		slices.SortFunc(ranges, func(a, b protocol.FoldingRange) int {

      
        25
        
        -			return cmp.Compare(a.StartLine, b.StartLine)

      
        26
        
        -		})

      
        27
        
        -		return ranges, nil

      
        
        19
        +	pf := parsedFileFor(an, params.TextDocument.URI.Path())

      
        
        20
        +	if pf == nil {

      
        
        21
        +		return nil, nil

      
        28
        22
         	}

      
        29
        
        -	return nil, nil

      
        
        23
        +	ranges := foldingRangesFor(pf.Ast.Entries)

      
        
        24
        +	slices.SortFunc(ranges, func(a, b protocol.FoldingRange) int {

      
        
        25
        +		return cmp.Compare(a.StartLine, b.StartLine)

      
        
        26
        +	})

      
        
        27
        +	return ranges, nil

      
        30
        28
         }

      
        31
        29
         

      
        32
        30
         // foldingRangesFor folds every foldable structure in a journal's top-level entries:

      
M internal/lsp/textdocument_hover.go
···
        61
        61
         // docPath. Only the entry containing the cursor can match; entries are in

      
        62
        62
         // file order, so [entryAt] finds the containing entry in O(log n).

      
        63
        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
        
        -		}

      
        
        64
        +	pf := parsedFileFor(an, docPath)

      
        
        65
        +	if pf == nil {

      
        71
        66
         		return nil

      
        
        67
        +	}

      
        
        68
        +	if entry := entryAt(pf.Ast.Entries, cursor); entry != nil {

      
        
        69
        +		return hoverInEntry(content, entry, cursor)

      
        72
        70
         	}

      
        73
        71
         	return nil

      
        74
        72
         }

      
M internal/lsp/textdocument_rename.go
···
        76
        76
         }

      
        77
        77
         

      
        78
        78
         func findSymbolUnderCursor(an *analyzer.Analysis, docPath, content string, cursor int) *symbolRef {

      
        79
        
        -	for _, pf := range an.Files {

      
        80
        
        -		if pf.Path != docPath {

      
        81
        
        -			continue

      
        82
        
        -		}

      
        83
        
        -		if entry := entryAt(pf.Ast.Entries, cursor); entry != nil {

      
        84
        
        -			return symbolInEntry(content, entry, cursor)

      
        85
        
        -		}

      
        
        79
        +	pf := parsedFileFor(an, docPath)

      
        
        80
        +	if pf == nil {

      
        86
        81
         		return nil

      
        
        82
        +	}

      
        
        83
        +	if entry := entryAt(pf.Ast.Entries, cursor); entry != nil {

      
        
        84
        +		return symbolInEntry(content, entry, cursor)

      
        87
        85
         	}

      
        88
        86
         	return nil

      
        89
        87
         }

      
A internal/lsp/textdocument_selection_range.go
···
        
        1
        +package lsp

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +

      
        
        6
        +	"go.lsp.dev/protocol"

      
        
        7
        +

      
        
        8
        +	"olexsmir.xyz/clerk/internal/lsp/lsputil"

      
        
        9
        +	"olexsmir.xyz/clerk/journal"

      
        
        10
        +	"olexsmir.xyz/clerk/journal/ast"

      
        
        11
        +	"olexsmir.xyz/clerk/journal/token"

      
        
        12
        +)

      
        
        13
        +

      
        
        14
        +func (s *server) SelectionRange(_ context.Context, params *protocol.SelectionRangeParams) ([]protocol.SelectionRange, error) {

      
        
        15
        +	u := params.TextDocument.URI

      
        
        16
        +	state, ok := s.getDocState(u)

      
        
        17
        +	if !ok {

      
        
        18
        +		return nil, nil

      
        
        19
        +	}

      
        
        20
        +	an := s.analysisFor(u)

      
        
        21
        +	if an == nil {

      
        
        22
        +		return nil, nil

      
        
        23
        +	}

      
        
        24
        +	pf := parsedFileFor(an, u.Path())

      
        
        25
        +	if pf == nil {

      
        
        26
        +		return nil, nil

      
        
        27
        +	}

      
        
        28
        +

      
        
        29
        +	li := state.lineIdx

      
        
        30
        +	docSel := protocol.SelectionRange{Range: li.SpanRange(fullDocSpan(pf.Src))}

      
        
        31
        +	out := make([]protocol.SelectionRange, len(params.Positions))

      
        
        32
        +	for i, pos := range params.Positions {

      
        
        33
        +		cursor := li.Offset(int(pos.Line), int(pos.Character))

      
        
        34
        +		out[i] = selectionAt(state.text, pf, li, cursor, docSel)

      
        
        35
        +	}

      
        
        36
        +	return out, nil

      
        
        37
        +}

      
        
        38
        +

      
        
        39
        +func fullDocSpan(src []byte) token.Span { return token.Span{End: token.Pos{Offset: len(src)}} }

      
        
        40
        +

      
        
        41
        +func selectionAt(content string, pf *journal.ParsedFile, li *lsputil.LineIndex, cursor int, docSel protocol.SelectionRange) protocol.SelectionRange {

      
        
        42
        +	e := entryAt(pf.Ast.Entries, cursor)

      
        
        43
        +	if e == nil {

      
        
        44
        +		return docSel

      
        
        45
        +	}

      
        
        46
        +	es := entrySpan(e)

      
        
        47
        +	if !spanContains(content, es, cursor) {

      
        
        48
        +		return docSel

      
        
        49
        +	}

      
        
        50
        +	switch c := e.(type) {

      
        
        51
        +	case *ast.BlankLine:

      
        
        52
        +		return docSel

      
        
        53
        +	case *ast.Comment: // the comment span is the entry span itself: no extra level

      
        
        54
        +		return commentOrParent(content, c, li, cursor, docSel)

      
        
        55
        +	}

      
        
        56
        +	parent := protocol.SelectionRange{Range: li.SpanRange(es), Parent: &docSel}

      
        
        57
        +	return selectionInEntry(content, e, li, cursor, parent)

      
        
        58
        +}

      
        
        59
        +

      
        
        60
        +func selectionInEntry(content string, e ast.Entry, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) protocol.SelectionRange {

      
        
        61
        +	switch t := e.(type) {

      
        
        62
        +	case *ast.Transaction:

      
        
        63
        +		return transactionSelection(content, t, li, cursor, parent)

      
        
        64
        +	case *ast.PeriodicTransaction:

      
        
        65
        +		return periodicSelection(content, t, li, cursor, parent)

      
        
        66
        +	case *ast.AutomatedTransaction:

      
        
        67
        +		return automatedSelection(content, t, li, cursor, parent)

      
        
        68
        +	case *ast.AccountDirective:

      
        
        69
        +		return accountDirectiveSelection(content, t, li, cursor, parent)

      
        
        70
        +	case *ast.CommodityDirective:

      
        
        71
        +		return commodityDirectiveSelection(content, t, li, cursor, parent)

      
        
        72
        +	case *ast.PayeeDirective:

      
        
        73
        +		if t.Name != nil {

      
        
        74
        +			if sel, ok := selectionForSpan(content, li, t.Name.Span, cursor, parent); ok {

      
        
        75
        +				return sel

      
        
        76
        +			}

      
        
        77
        +		}

      
        
        78
        +		return commentOrParent(content, t.Comment, li, cursor, parent)

      
        
        79
        +	case *ast.TagDirective:

      
        
        80
        +		if sp, ok := tagDirectiveSpan(content, t); ok {

      
        
        81
        +			if sel, ok := selectionForSpan(content, li, sp, cursor, parent); ok {

      
        
        82
        +				return sel

      
        
        83
        +			}

      
        
        84
        +		}

      
        
        85
        +		return commentOrParent(content, t.Comment, li, cursor, parent)

      
        
        86
        +	case *ast.AliasDirective:

      
        
        87
        +		if sel, ok := accountSelection(content, &t.From, li, cursor, parent); ok {

      
        
        88
        +			return sel

      
        
        89
        +		}

      
        
        90
        +		if sel, ok := accountSelection(content, &t.To, li, cursor, parent); ok {

      
        
        91
        +			return sel

      
        
        92
        +		}

      
        
        93
        +		return commentOrParent(content, t.Comment, li, cursor, parent)

      
        
        94
        +	case *ast.DefaultCommodityDirective:

      
        
        95
        +		if sel, ok := amountSelection(content, &t.Amount, li, cursor, parent); ok {

      
        
        96
        +			return sel

      
        
        97
        +		}

      
        
        98
        +		return commentOrParent(content, t.Comment, li, cursor, parent)

      
        
        99
        +	case *ast.MarketPriceDirective:

      
        
        100
        +		if sel, ok := selectionForSpan(content, li, t.DateTime.Date.Span, cursor, parent); ok {

      
        
        101
        +			return sel

      
        
        102
        +		}

      
        
        103
        +		if t.DateTime.Time != nil {

      
        
        104
        +			if sel, ok := selectionForSpan(content, li, t.DateTime.Time.Span, cursor, parent); ok {

      
        
        105
        +				return sel

      
        
        106
        +			}

      
        
        107
        +		}

      
        
        108
        +		if sel, ok := amountSelection(content, &t.Amount, li, cursor, parent); ok {

      
        
        109
        +			return sel

      
        
        110
        +		}

      
        
        111
        +		return commentOrParent(content, t.Comment, li, cursor, parent)

      
        
        112
        +	case *ast.ConversionDirective:

      
        
        113
        +		if sel, ok := amountSelection(content, &t.From, li, cursor, parent); ok {

      
        
        114
        +			return sel

      
        
        115
        +		}

      
        
        116
        +		if sel, ok := amountSelection(content, &t.To, li, cursor, parent); ok {

      
        
        117
        +			return sel

      
        
        118
        +		}

      
        
        119
        +		return commentOrParent(content, t.Comment, li, cursor, parent)

      
        
        120
        +	}

      
        
        121
        +	return parent

      
        
        122
        +}

      
        
        123
        +

      
        
        124
        +func transactionSelection(content string, t *ast.Transaction, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) protocol.SelectionRange {

      
        
        125
        +	var header [6]token.Span // date, second date, status, code, payee, note

      
        
        126
        +	sps := append(header[:0], t.Date.Span)

      
        
        127
        +	if t.SecondDate != nil {

      
        
        128
        +		sps = append(sps, t.SecondDate.Span)

      
        
        129
        +	}

      
        
        130
        +	if t.Status.Value != ast.StatusNone {

      
        
        131
        +		sps = append(sps, t.Status.Span)

      
        
        132
        +	}

      
        
        133
        +	if t.Code != nil {

      
        
        134
        +		sps = append(sps, t.Code.Span)

      
        
        135
        +	}

      
        
        136
        +	if t.Payee != nil {

      
        
        137
        +		sps = append(sps, t.Payee.Span)

      
        
        138
        +	}

      
        
        139
        +	if t.Note != nil {

      
        
        140
        +		sps = append(sps, t.Note.Span)

      
        
        141
        +	}

      
        
        142
        +	for _, sp := range sps {

      
        
        143
        +		if sel, ok := selectionForSpan(content, li, sp, cursor, parent); ok {

      
        
        144
        +			return sel

      
        
        145
        +		}

      
        
        146
        +	}

      
        
        147
        +	return commentsAndPostingsSelection(content, t.Comment, t.HeaderComments, t.Postings, li, cursor, parent)

      
        
        148
        +}

      
        
        149
        +

      
        
        150
        +// commentsAndPostingsSelection descends into an entry's inline comment, header

      
        
        151
        +// comments, then postings; returns parent when none contains the cursor.

      
        
        152
        +func commentsAndPostingsSelection(content string, inline *ast.Comment, headers []*ast.Comment, postings []ast.Posting, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) protocol.SelectionRange {

      
        
        153
        +	if sel, ok := commentSelection(content, inline, li, cursor, parent); ok {

      
        
        154
        +		return sel

      
        
        155
        +	}

      
        
        156
        +	for _, c := range headers {

      
        
        157
        +		if sel, ok := commentSelection(content, c, li, cursor, parent); ok {

      
        
        158
        +			return sel

      
        
        159
        +		}

      
        
        160
        +	}

      
        
        161
        +	if sel, ok := postingsSelection(content, postings, li, cursor, parent); ok {

      
        
        162
        +		return sel

      
        
        163
        +	}

      
        
        164
        +	return parent

      
        
        165
        +}

      
        
        166
        +

      
        
        167
        +func periodicSelection(content string, pt *ast.PeriodicTransaction, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) protocol.SelectionRange {

      
        
        168
        +	if sel, ok := selectionForSpan(content, li, pt.Period.Span, cursor, parent); ok {

      
        
        169
        +		if d := pt.Period.From; d != nil {

      
        
        170
        +			if sub, ok := selectionForSpan(content, li, d.Span, cursor, sel); ok {

      
        
        171
        +				return sub

      
        
        172
        +			}

      
        
        173
        +		}

      
        
        174
        +		if d := pt.Period.To; d != nil {

      
        
        175
        +			if sub, ok := selectionForSpan(content, li, d.Span, cursor, sel); ok {

      
        
        176
        +				return sub

      
        
        177
        +			}

      
        
        178
        +		}

      
        
        179
        +		return sel

      
        
        180
        +	}

      
        
        181
        +	if pt.Description != nil {

      
        
        182
        +		if sel, ok := selectionForSpan(content, li, pt.Description.Span, cursor, parent); ok {

      
        
        183
        +			return sel

      
        
        184
        +		}

      
        
        185
        +	}

      
        
        186
        +	return commentsAndPostingsSelection(content, pt.Comment, pt.HeaderComments, pt.Postings, li, cursor, parent)

      
        
        187
        +}

      
        
        188
        +

      
        
        189
        +func automatedSelection(content string, at *ast.AutomatedTransaction, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) protocol.SelectionRange {

      
        
        190
        +	if sel, ok := selectionForSpan(content, li, at.Expr.Span, cursor, parent); ok {

      
        
        191
        +		return sel

      
        
        192
        +	}

      
        
        193
        +	return commentsAndPostingsSelection(content, at.Comment, at.HeaderComments, at.Postings, li, cursor, parent)

      
        
        194
        +}

      
        
        195
        +

      
        
        196
        +func accountDirectiveSelection(content string, d *ast.AccountDirective, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) protocol.SelectionRange {

      
        
        197
        +	if sel, ok := accountSelection(content, &d.Account, li, cursor, parent); ok {

      
        
        198
        +		return sel

      
        
        199
        +	}

      
        
        200
        +	for i := range d.Subdirectives {

      
        
        201
        +		sd := &d.Subdirectives[i]

      
        
        202
        +		if sd.Kind == ast.SubdirectiveComment {

      
        
        203
        +			if sel, ok := commentSelection(content, sd.Comment, li, cursor, parent); ok {

      
        
        204
        +				return sel

      
        
        205
        +			}

      
        
        206
        +			continue

      
        
        207
        +		}

      
        
        208
        +		if sel, ok := selectionForSpan(content, li, sd.ValueSpan, cursor, parent); ok {

      
        
        209
        +			return sel

      
        
        210
        +		}

      
        
        211
        +	}

      
        
        212
        +	return commentOrParent(content, d.Comment, li, cursor, parent)

      
        
        213
        +}

      
        
        214
        +

      
        
        215
        +func commodityDirectiveSelection(content string, d *ast.CommodityDirective, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) protocol.SelectionRange {

      
        
        216
        +	if sel, ok := selectionForSpan(content, li, d.CommoditySpan, cursor, parent); ok {

      
        
        217
        +		return sel

      
        
        218
        +	}

      
        
        219
        +	if d.FormatSub != nil {

      
        
        220
        +		if sel, ok := amountSelection(content, &d.FormatSub.Amount, li, cursor, parent); ok {

      
        
        221
        +			return sel

      
        
        222
        +		}

      
        
        223
        +	}

      
        
        224
        +	return commentOrParent(content, d.Comment, li, cursor, parent)

      
        
        225
        +}

      
        
        226
        +

      
        
        227
        +// postingsSelection returns the selection inside the posting containing cursor.

      
        
        228
        +func postingsSelection(content string, postings []ast.Posting, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) (protocol.SelectionRange, bool) {

      
        
        229
        +	for i := range postings {

      
        
        230
        +		p := &postings[i]

      
        
        231
        +		postingSel, ok := selectionForSpan(content, li, p.Span, cursor, parent)

      
        
        232
        +		if !ok {

      
        
        233
        +			continue

      
        
        234
        +		}

      
        
        235
        +		return postingSelection(content, p, li, cursor, postingSel), true

      
        
        236
        +	}

      
        
        237
        +	return protocol.SelectionRange{}, false

      
        
        238
        +}

      
        
        239
        +

      
        
        240
        +func postingSelection(content string, p *ast.Posting, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) protocol.SelectionRange {

      
        
        241
        +	if p.Status.Value != ast.StatusNone {

      
        
        242
        +		if sel, ok := selectionForSpan(content, li, p.Status.Span, cursor, parent); ok {

      
        
        243
        +			return sel

      
        
        244
        +		}

      
        
        245
        +	}

      
        
        246
        +	if sel, ok := accountSelection(content, &p.Account, li, cursor, parent); ok {

      
        
        247
        +		return sel

      
        
        248
        +	}

      
        
        249
        +	if sel, ok := amountSelection(content, p.Amount, li, cursor, parent); ok {

      
        
        250
        +		return sel

      
        
        251
        +	}

      
        
        252
        +	if p.Cost != nil {

      
        
        253
        +		if sel, ok := selectionForSpan(content, li, p.Cost.Span, cursor, parent); ok {

      
        
        254
        +			return sel

      
        
        255
        +		}

      
        
        256
        +	}

      
        
        257
        +	if p.Balance != nil {

      
        
        258
        +		if sel, ok := selectionForSpan(content, li, p.Balance.Span, cursor, parent); ok {

      
        
        259
        +			return sel

      
        
        260
        +		}

      
        
        261
        +	}

      
        
        262
        +	if sel, ok := commentSelection(content, p.Comment, li, cursor, parent); ok {

      
        
        263
        +		return sel

      
        
        264
        +	}

      
        
        265
        +	for i := range p.Comments {

      
        
        266
        +		if sel, ok := commentSelection(content, &p.Comments[i], li, cursor, parent); ok {

      
        
        267
        +			return sel

      
        
        268
        +		}

      
        
        269
        +	}

      
        
        270
        +	return parent

      
        
        271
        +}

      
        
        272
        +

      
        
        273
        +func accountSelection(content string, a *ast.Account, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) (protocol.SelectionRange, bool) {

      
        
        274
        +	accountSel, ok := selectionForSpan(content, li, a.Span, cursor, parent)

      
        
        275
        +	if !ok {

      
        
        276
        +		return protocol.SelectionRange{}, false

      
        
        277
        +	}

      
        
        278
        +	if len(a.Name) <= 1 {

      
        
        279
        +		return accountSel, true

      
        
        280
        +	}

      
        
        281
        +	for i := range a.Name {

      
        
        282
        +		if sel, ok := selectionForSpan(content, li, a.Name[i].Span, cursor, accountSel); ok {

      
        
        283
        +			return sel, true

      
        
        284
        +		}

      
        
        285
        +	}

      
        
        286
        +	return accountSel, true

      
        
        287
        +}

      
        
        288
        +

      
        
        289
        +func amountSelection(content string, am *ast.Amount, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) (protocol.SelectionRange, bool) {

      
        
        290
        +	if am == nil {

      
        
        291
        +		return protocol.SelectionRange{}, false

      
        
        292
        +	}

      
        
        293
        +	amountSel, ok := selectionForSpan(content, li, am.Span, cursor, parent)

      
        
        294
        +	if !ok {

      
        
        295
        +		return protocol.SelectionRange{}, false

      
        
        296
        +	}

      
        
        297
        +	if sel, ok := selectionForSpan(content, li, am.CommoditySpan, cursor, amountSel); ok {

      
        
        298
        +		return sel, true

      
        
        299
        +	}

      
        
        300
        +	qStart, qEnd := quantitySpan(content, am)

      
        
        301
        +	if qEnd > qStart {

      
        
        302
        +		if sel, ok := selectionForSpan(content, li, token.Span{Start: token.Pos{Offset: qStart}, End: token.Pos{Offset: qEnd}}, cursor, amountSel); ok {

      
        
        303
        +			return sel, true

      
        
        304
        +		}

      
        
        305
        +	}

      
        
        306
        +	return amountSel, true

      
        
        307
        +}

      
        
        308
        +

      
        
        309
        +// selectionForSpan selects span nested in parent when span contains cursor.

      
        
        310
        +func selectionForSpan(content string, li *lsputil.LineIndex, span token.Span, cursor int, parent protocol.SelectionRange) (protocol.SelectionRange, bool) {

      
        
        311
        +	if !spanContains(content, span, cursor) {

      
        
        312
        +		return protocol.SelectionRange{}, false

      
        
        313
        +	}

      
        
        314
        +	return protocol.SelectionRange{Range: li.SpanRange(span), Parent: &parent}, true

      
        
        315
        +}

      
        
        316
        +

      
        
        317
        +// commentSelection returns a selection inside comment when the cursor is on it:

      
        
        318
        +// the tag key when the cursor is on a tag, else the whole comment line.

      
        
        319
        +func commentSelection(content string, c *ast.Comment, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) (protocol.SelectionRange, bool) {

      
        
        320
        +	if c == nil {

      
        
        321
        +		return protocol.SelectionRange{}, false

      
        
        322
        +	}

      
        
        323
        +	commentSel, ok := selectionForSpan(content, li, c.Span, cursor, parent)

      
        
        324
        +	if !ok {

      
        
        325
        +		return protocol.SelectionRange{}, false

      
        
        326
        +	}

      
        
        327
        +	if ref := tagRefInComment(content, c, cursor); ref != nil {

      
        
        328
        +		return protocol.SelectionRange{Range: li.SpanRange(ref.span), Parent: &commentSel}, true

      
        
        329
        +	}

      
        
        330
        +	return commentSel, true

      
        
        331
        +}

      
        
        332
        +

      
        
        333
        +// commentOrParent is commentSelection with parent as the fallback.

      
        
        334
        +func commentOrParent(content string, c *ast.Comment, li *lsputil.LineIndex, cursor int, parent protocol.SelectionRange) protocol.SelectionRange {

      
        
        335
        +	if sel, ok := commentSelection(content, c, li, cursor, parent); ok {

      
        
        336
        +		return sel

      
        
        337
        +	}

      
        
        338
        +	return parent

      
        
        339
        +}

      
A internal/lsp/textdocument_selection_range_test.go
···
        
        1
        +package lsp

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +	"path/filepath"

      
        
        6
        +	"strings"

      
        
        7
        +	"testing"

      
        
        8
        +

      
        
        9
        +	"go.lsp.dev/protocol"

      
        
        10
        +	"go.lsp.dev/uri"

      
        
        11
        +

      
        
        12
        +	"olexsmir.xyz/clerk/internal/lsp/lsputil"

      
        
        13
        +	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        
        14
        +	"olexsmir.xyz/clerk/journal"

      
        
        15
        +)

      
        
        16
        +

      
        
        17
        +func TestServer_SelectionRange_DocumentNotFound(t *testing.T) {

      
        
        18
        +	srv := newServer(t)

      
        
        19
        +	res, err := srv.server.SelectionRange(t.Context(), &protocol.SelectionRangeParams{

      
        
        20
        +		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")},

      
        
        21
        +		Positions:    []protocol.Position{{Line: 0, Character: 0}},

      
        
        22
        +	})

      
        
        23
        +	if err != nil {

      
        
        24
        +		t.Fatal(err)

      
        
        25
        +	}

      
        
        26
        +	if res != nil {

      
        
        27
        +		t.Errorf("got %v, want nil", res)

      
        
        28
        +	}

      
        
        29
        +}

      
        
        30
        +

      
        
        31
        +func TestGolden_SelectionRange(t *testing.T) {

      
        
        32
        +	ar := golden.Read(t, "selection-range")

      
        
        33
        +	h := newTxtarHarness(t, ar)

      
        
        34
        +	li := lsputil.NewLineIndex(h.content)

      
        
        35
        +

      
        
        36
        +	var b strings.Builder

      
        
        37
        +	for i := range h.cursors {

      
        
        38
        +		pos := lsputil.Position(h.content, h.cursors[i])

      
        
        39
        +		res, err := h.srv.SelectionRange(t.Context(), &protocol.SelectionRangeParams{

      
        
        40
        +			TextDocument: protocol.TextDocumentIdentifier{URI: h.uri},

      
        
        41
        +			Positions:    []protocol.Position{pos},

      
        
        42
        +		})

      
        
        43
        +		if err != nil {

      
        
        44
        +			t.Fatal(err)

      
        
        45
        +		}

      
        
        46
        +		if len(res) != 1 {

      
        
        47
        +			t.Fatalf("cursor %d: got %d results, want 1", i, len(res))

      
        
        48
        +		}

      
        
        49
        +		chain := make([]string, 0, 4)

      
        
        50
        +		for cur := &res[0]; ; cur = cur.Parent {

      
        
        51
        +			if cur.Parent == nil {

      
        
        52
        +				chain = append(chain, "<doc>")

      
        
        53
        +				break

      
        
        54
        +			}

      
        
        55
        +			start := li.Offset(int(cur.Range.Start.Line), int(cur.Range.Start.Character))

      
        
        56
        +			end := li.Offset(int(cur.Range.End.Line), int(cur.Range.End.Character))

      
        
        57
        +			chain = append(chain, fmt.Sprintf("%q", h.content[start:end]))

      
        
        58
        +		}

      
        
        59
        +		fmt.Fprintf(&b, "%d:%d %s\n", pos.Line, pos.Character, strings.Join(chain, " -> "))

      
        
        60
        +	}

      
        
        61
        +	golden.Assert(t, ar, b.String())

      
        
        62
        +}

      
        
        63
        +

      
        
        64
        +func BenchmarkSelectionRange(b *testing.B) {

      
        
        65
        +	path := "../../journal/testdata/journals/actual-1ktxns-100accts.journal"

      
        
        66
        +	abs, err := filepath.Abs(path)

      
        
        67
        +	if err != nil {

      
        
        68
        +		b.Fatal(err)

      
        
        69
        +	}

      
        
        70
        +	rj, err := journal.NewLoader().Resolve(abs)

      
        
        71
        +	if err != nil {

      
        
        72
        +		b.Fatal(err)

      
        
        73
        +	}

      
        
        74
        +	content := string(rj.Occurrences[0].Src)

      
        
        75
        +

      
        
        76
        +	srv := newServer(b)

      
        
        77
        +	u := uri.File(abs)

      
        
        78
        +	srv.server.openDoc(u, content, 1, "journal")

      
        
        79
        +	srv.server.analysisFor(u) // warm the per-doc cache

      
        
        80
        +

      
        
        81
        +	for tname, off := range map[string]int{

      
        
        82
        +		"account seg, tx 3, early":  strings.Index(content, "  1:2:3:4:5 ") + 5,

      
        
        83
        +		"date, tx 1, early":         strings.Index(content, "2000-01-01 ") + 3,

      
        
        84
        +		"account seg, tx 999, late": strings.Index(content, "  5b:5c:5d:5e:5f:60:61 ") + 6,

      
        
        85
        +		"date, tx 999, late":        strings.Index(content, "2002-09-25 ") + 3,

      
        
        86
        +	} {

      
        
        87
        +		b.Run(tname, func(b *testing.B) {

      
        
        88
        +			params := &protocol.SelectionRangeParams{

      
        
        89
        +				TextDocument: protocol.TextDocumentIdentifier{URI: u},

      
        
        90
        +				Positions:    []protocol.Position{lsputil.Position(content, off)},

      
        
        91
        +			}

      
        
        92
        +			// warm up: first request resolves the lazily-built analysis; assert it found one

      
        
        93
        +			res, err := srv.server.SelectionRange(b.Context(), params)

      
        
        94
        +			if err != nil {

      
        
        95
        +				b.Fatal(err)

      
        
        96
        +			}

      
        
        97
        +			if len(res) != 1 {

      
        
        98
        +				b.Fatalf("%s: got %d results, want 1", tname, len(res))

      
        
        99
        +			}

      
        
        100
        +			b.ReportAllocs()

      
        
        101
        +			b.ResetTimer()

      
        
        102
        +			for b.Loop() {

      
        
        103
        +				if _, err := srv.server.SelectionRange(b.Context(), params); err != nil {

      
        
        104
        +					b.Fatal(err)

      
        
        105
        +				}

      
        
        106
        +			}

      
        
        107
        +		})

      
        
        108
        +	}

      
        
        109
        +}