11 files changed,
646 insertions(+),
59 deletions(-)
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
jump to
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 +}