5 files changed,
601 insertions(+),
0 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-13 13:40:52 +0300
Authored at:
2026-08-13 11:36:32 +0300
Change ID:
xmtzvytrtvzqqnmvlnqonvvrwlynzmnw
Parent:
f9e2eff
M
internal/lsp/server.go
··· 72 72 Capabilities: protocol.ServerCapabilities{ 73 73 DocumentFormattingProvider: &protocol.DocumentFormattingOptions{}, 74 74 DefinitionProvider: protocol.Boolean(true), 75 + HoverProvider: protocol.Boolean(true), 75 76 RenameProvider: &protocol.RenameOptions{ 76 77 PrepareProvider: new(true), 77 78 },
A
internal/lsp/testdata/hover-include.txtar
··· 1 +-- in.journal -- 2 +include sub.journal 3 + 4 +2024-01-16 Another Store 5 + expenses:food 100.00 E^UR 6 + assets:ca^sh 7 + 8 +-- sub.journal -- 9 +account expenses:food 10 + 11 +2024-01-15 Gro^cery Store 12 + expenses:fo^od $50 13 + assets:cash 14 + 15 +-- expect -- 16 +3:27 "**Amount:** 100.00 EUR" 3:19-3:29 17 +4:13 "**Account:** `assets:cash`\n**Postings:** 2\n**Last used:** 2024-01-16" 4:4-4:15
A
internal/lsp/testdata/hover-journal.txtar
··· 1 +-- in.journal -- 2 +account expenses:fo^od 3 +account assets:cash 4 + alias check^ing 5 +alias fo^od = expenses:food 6 +alias food = expenses:fo^od 7 +commodity E^UR 8 +payee Gro^cery Store 9 +tag cli^ent 10 + 11 +^2024-01-15 Gro^cery Store ; proj^ect:ho^me 12 + expenses:food ^$50^.00 @ ^$1.5 13 + assets:checking $5^0.00 @ $1.5 14 + assets:cash = ^$30 15 + ; cl^ient:acme 16 + ; completed:^ 17 + 18 +2024-01-15 Market 19 + expenses:food $5 20 + assets:cash $-5 21 + 22 +2024-01-16 Grocery Store ; project:wo^rk 23 + expenses:food 1.000,00 E^UR 24 + assets:cash -1.000,0^0 EUR 25 + 26 +2024-01-17 прод^укти 27 + expenses:fo^od $10 28 + assets:ca^sh 29 + 30 +^ 31 +~ monthly 32 + expenses:fo^od $1 33 + assets:cash 34 + 35 += expenses:food > 100 36 + assets:ca^sh $-1 37 + 38 +-- expect -- 39 +0:19 "**Account:** `expenses:food`\n**Postings:** 5\n**Last used:** 2024-01-17" 0:8-0:21 40 +2:13 "**Account:** `assets:cash`\n**Postings:** 6\n**Last used:** 2024-01-17" 2:8-2:16 41 +3:8 "**Account:** `expenses:food`\n**Postings:** 5\n**Last used:** 2024-01-17" 3:6-3:10 42 +4:24 "**Account:** `expenses:food`\n**Postings:** 5\n**Last used:** 2024-01-17" 4:13-4:26 43 +5:11 "**Commodity:** `EUR`\n**Usage:** 2\n**Last used:** 2024-01-16" 5:10-5:13 44 +6:9 "**Payee:** Grocery Store\n**Transactions:** 2" 6:6-6:19 45 +7:7 "**Tag:** `client`\n**Usage:** 1\n**Values:**\n- `acme`" 7:4-7:10 46 +9:0 "**Date:** 2024-01-15\n\n**Payee:** Grocery Store\n\n**Transactions:** 2\n\n**Postings:** 3" 9:0-9:10 47 +9:14 "**Payee:** Grocery Store\n**Transactions:** 2" 9:11-9:24 48 +9:31 "**Tag:** `project`\n**Usage:** 2\n**Values:**\n- `home`\n- `work`" 9:27-9:34 49 +9:37 "**Tag:** `project`\n**Value:** `home`\n**Usage:** 1" 9:35-9:39 50 +10:19 "**Amount:** $50.00\n\n**Unit cost:** @ $1.5" 10:19-10:25 51 +10:22 "**Amount:** $50.00\n\n**Unit cost:** @ $1.5" 10:19-10:25 52 +10:28 "**Commodity:** `$`\n**Usage:** 10\n**Last used:** 2024-01-17" 10:28-10:29 53 +11:23 "**Amount:** $50.00\n\n**Unit cost:** @ $1.5" 11:21-11:27 54 +12:19 "**Commodity:** `$`\n**Usage:** 10\n**Last used:** 2024-01-17" 12:19-12:20 55 +13:8 "**Tag:** `client`\n**Usage:** 1\n**Values:**\n- `acme`" 13:6-13:12 56 +14:16 "**Tag:** `completed`\n**Value:** *(empty)*\n**Usage:** 1" 14:16-14:16 57 +20:37 "**Tag:** `project`\n**Value:** `work`\n**Usage:** 1" 20:35-20:39 58 +21:29 "**Amount:** 1.000,00 EUR" 21:19-21:31 59 +22:25 "**Amount:** -1.000,00 EUR" 22:17-22:30 60 +24:15 "**Payee:** продукти\n**Transactions:** 1" 24:11-24:19 61 +25:15 "**Account:** `expenses:food`\n**Postings:** 5\n**Last used:** 2024-01-17" 25:4-25:17 62 +26:13 "**Account:** `assets:cash`\n**Postings:** 6\n**Last used:** 2024-01-17" 26:4-26:15 63 +28:0 <none> 64 +30:15 "**Account:** `expenses:food`\n**Postings:** 5\n**Last used:** 2024-01-17" 30:4-30:17 65 +34:13 "**Account:** `assets:cash`\n**Postings:** 6\n**Last used:** 2024-01-17" 34:4-34:15
A
internal/lsp/textdocument_hover.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + "go.lsp.dev/protocol" 9 + 10 + "olexsmir.xyz/clerk/internal/analyzer" 11 + "olexsmir.xyz/clerk/internal/lsp/lsputil" 12 + "olexsmir.xyz/clerk/journal/ast" 13 + "olexsmir.xyz/clerk/journal/token" 14 +) 15 + 16 +func (s *server) Hover(_ context.Context, params *protocol.HoverParams) (*protocol.Hover, error) { 17 + state, ok := s.getDocState(params.TextDocument.URI) 18 + if !ok { 19 + return nil, nil 20 + } 21 + 22 + an := s.analysis() 23 + cursor := lsputil.Offset(state.text, int(params.Position.Line), int(params.Position.Character)) 24 + el := hoverAt(an, params.TextDocument.URI.Path(), state.text, cursor) 25 + if el == nil { 26 + return nil, nil 27 + } 28 + 29 + return &protocol.Hover{ 30 + Contents: &protocol.MarkupContent{ 31 + Kind: protocol.MarkupKindMarkdown, 32 + Value: buildHoverContent(an, state.text, el), 33 + }, 34 + Range: new(spanToProtocolRange(state.text, el.span)), 35 + }, nil 36 +} 37 + 38 +type hoverKind int 39 + 40 +const ( 41 + hoverAccount hoverKind = iota 42 + hoverCommodity 43 + hoverPayee 44 + hoverTagKey 45 + hoverTagValue 46 + hoverAmount 47 + hoverDate 48 +) 49 + 50 +type hoverElement struct { 51 + kind hoverKind 52 + span token.Span 53 + name string // account, commodity or payee name 54 + tagKey string 55 + tagValue string 56 + amount *ast.Amount 57 + cost *ast.Cost 58 + tx *ast.Transaction // date hover 59 +} 60 + 61 +func hoverAt(an *analyzer.Analysis, docPath, content string, cursor int) *hoverElement { 62 + for _, pf := range an.Files { 63 + if pf.Path != docPath { 64 + continue 65 + } 66 + for _, entry := range pf.Ast.Entries { 67 + if el := hoverInEntry(content, entry, cursor); el != nil { 68 + return el 69 + } 70 + } 71 + return nil 72 + } 73 + return nil 74 +} 75 + 76 +func hoverInEntry(content string, e ast.Entry, cursor int) *hoverElement { 77 + switch e := e.(type) { 78 + case *ast.Transaction: 79 + if e.Payee != nil && spanContains(content, e.Payee.Span, cursor) { 80 + return &hoverElement{kind: hoverPayee, span: e.Payee.Span, name: e.Payee.Name} 81 + } 82 + if spanContains(content, e.Date.Span, cursor) { 83 + return &hoverElement{kind: hoverDate, span: e.Date.Span, tx: e} 84 + } 85 + if el := hoverTagInComment(content, e.Comment, cursor); el != nil { 86 + return el 87 + } 88 + for _, c := range e.HeaderComments { 89 + if el := hoverTagInComment(content, c, cursor); el != nil { 90 + return el 91 + } 92 + } 93 + return hoverInPostings(content, e.Postings, cursor) 94 + case *ast.PeriodicTransaction: 95 + if el := hoverTagInComment(content, e.Comment, cursor); el != nil { 96 + return el 97 + } 98 + for _, c := range e.HeaderComments { 99 + if el := hoverTagInComment(content, c, cursor); el != nil { 100 + return el 101 + } 102 + } 103 + return hoverInPostings(content, e.Postings, cursor) 104 + case *ast.AutomatedTransaction: 105 + if el := hoverTagInComment(content, e.Comment, cursor); el != nil { 106 + return el 107 + } 108 + for _, c := range e.HeaderComments { 109 + if el := hoverTagInComment(content, c, cursor); el != nil { 110 + return el 111 + } 112 + } 113 + return hoverInPostings(content, e.Postings, cursor) 114 + case *ast.Comment: 115 + return hoverTagInComment(content, e, cursor) 116 + case *ast.AccountDirective: 117 + if spanContains(content, e.Account.Span, cursor) { 118 + return &hoverElement{kind: hoverAccount, span: e.Account.Span, name: e.Account.String()} 119 + } 120 + for _, sd := range e.Subdirectives { 121 + if sd.Name == "alias" && spanContains(content, sd.ValueSpan, cursor) { 122 + return &hoverElement{kind: hoverAccount, span: sd.ValueSpan, name: sd.Value} 123 + } 124 + } 125 + case *ast.AliasDirective: 126 + if spanContains(content, e.From.Span, cursor) { 127 + return &hoverElement{kind: hoverAccount, span: e.From.Span, name: e.From.String()} 128 + } 129 + if spanContains(content, e.To.Span, cursor) { 130 + return &hoverElement{kind: hoverAccount, span: e.To.Span, name: e.To.String()} 131 + } 132 + case *ast.CommodityDirective: 133 + if spanContains(content, e.CommoditySpan, cursor) { 134 + return &hoverElement{kind: hoverCommodity, span: e.CommoditySpan, name: e.Commodity} 135 + } 136 + case *ast.PayeeDirective: 137 + if e.Name != nil && spanContains(content, e.Name.Span, cursor) { 138 + return &hoverElement{kind: hoverPayee, span: e.Name.Span, name: e.Name.Name} 139 + } 140 + case *ast.TagDirective: 141 + if e.Name != "" { 142 + if span, ok := tagDirectiveSpan(content, e); ok && spanContains(content, span, cursor) { 143 + return &hoverElement{kind: hoverTagKey, span: span, tagKey: e.Name} 144 + } 145 + } 146 + } 147 + return nil 148 +} 149 + 150 +func hoverInPostings(content string, postings []*ast.Posting, cursor int) *hoverElement { 151 + for _, p := range postings { 152 + if spanContains(content, p.Account.Span, cursor) { 153 + return &hoverElement{kind: hoverAccount, span: p.Account.Span, name: p.Account.String()} 154 + } 155 + if p.Amount != nil { 156 + if spanContains(content, p.Amount.Span, cursor) { 157 + return &hoverElement{kind: hoverAmount, span: p.Amount.Span, amount: p.Amount, cost: p.Cost} 158 + } 159 + if ref := commodityRef(content, p.Amount, cursor); ref != nil { 160 + return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name} 161 + } 162 + } 163 + if p.Cost != nil { 164 + if ref := commodityRef(content, &p.Cost.Amount, cursor); ref != nil { 165 + return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name} 166 + } 167 + } 168 + if p.Balance != nil { 169 + if ref := commodityRef(content, &p.Balance.Amount, cursor); ref != nil { 170 + return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name} 171 + } 172 + if p.Balance.Cost != nil { 173 + if ref := commodityRef(content, &p.Balance.Cost.Amount, cursor); ref != nil { 174 + return &hoverElement{kind: hoverCommodity, span: ref.span, name: ref.name} 175 + } 176 + } 177 + } 178 + if el := hoverTagInComment(content, p.Comment, cursor); el != nil { 179 + return el 180 + } 181 + for i := range p.Comments { 182 + if el := hoverTagInComment(content, &p.Comments[i], cursor); el != nil { 183 + return el 184 + } 185 + } 186 + } 187 + return nil 188 +} 189 + 190 +func hoverTagInComment(content string, c *ast.Comment, cursor int) *hoverElement { 191 + if c == nil { 192 + return nil 193 + } 194 + for i := range c.Tags { 195 + t := &c.Tags[i] 196 + if !spanContains(content, t.Span, cursor) { 197 + continue 198 + } 199 + keySpan := tagKeySpan(content, t) 200 + if spanContains(content, keySpan, cursor) { 201 + return &hoverElement{kind: hoverTagKey, span: keySpan, tagKey: t.Key} 202 + } 203 + return &hoverElement{kind: hoverTagValue, span: tagValueSpan(content, t), tagKey: t.Key, tagValue: t.Value} 204 + } 205 + return nil 206 +} 207 + 208 +func buildHoverContent(an *analyzer.Analysis, content string, el *hoverElement) string { 209 + switch el.kind { 210 + case hoverAccount: 211 + return buildAccountHover(an, el.name) 212 + case hoverCommodity: 213 + return buildCommodityHover(an, el.name) 214 + case hoverPayee: 215 + return buildPayeeHover(an, el.name) 216 + case hoverTagKey: 217 + return buildTagHover(an, el.tagKey) 218 + case hoverTagValue: 219 + return buildTagValueHover(an, el.tagKey, el.tagValue) 220 + case hoverAmount: 221 + return buildAmountHover(content, el.amount, el.cost) 222 + case hoverDate: 223 + return buildDateHover(an, el.tx) 224 + default: 225 + return "" 226 + } 227 +} 228 + 229 +func buildAccountHover(an *analyzer.Analysis, name string) string { 230 + if canon, ok := an.AccountAliases[name]; ok { 231 + name = canon 232 + } 233 + var sb strings.Builder 234 + fmt.Fprintf(&sb, "**Account:** `%s`", name) 235 + if info := an.Accounts[name]; info != nil { 236 + fmt.Fprintf(&sb, "\n**Postings:** %d", info.UsedCount) 237 + writeDateSection(&sb, "**Last used:**", info.LastUsed) 238 + } 239 + return sb.String() 240 +} 241 + 242 +func buildCommodityHover(an *analyzer.Analysis, symbol string) string { 243 + var sb strings.Builder 244 + fmt.Fprintf(&sb, "**Commodity:** `%s`", symbol) 245 + if info := an.Commodities[symbol]; info != nil { 246 + fmt.Fprintf(&sb, "\n**Usage:** %d", info.UsedCount) 247 + writeDateSection(&sb, "**Last used:**", info.LastUsed) 248 + } 249 + return sb.String() 250 +} 251 + 252 +func buildPayeeHover(an *analyzer.Analysis, name string) string { 253 + var sb strings.Builder 254 + fmt.Fprintf(&sb, "**Payee:** %s", name) 255 + if info := an.Payees[name]; info != nil { 256 + fmt.Fprintf(&sb, "\n**Transactions:** %d", info.UsedCount) 257 + } 258 + return sb.String() 259 +} 260 + 261 +func buildTagHover(an *analyzer.Analysis, key string) string { 262 + var sb strings.Builder 263 + fmt.Fprintf(&sb, "**Tag:** `%s`", key) 264 + if info := an.Tags[key]; info != nil { 265 + fmt.Fprintf(&sb, "\n**Usage:** %d", info.UsedCount) 266 + if len(info.Values) > 0 { 267 + sb.WriteString("\n**Values:**\n") 268 + for i, v := range info.Values { 269 + if i > 0 { 270 + sb.WriteByte('\n') 271 + } 272 + if v == "" { 273 + sb.WriteString("- *(empty)*") 274 + } else { 275 + fmt.Fprintf(&sb, "- `%s`", v) 276 + } 277 + } 278 + } 279 + } 280 + return sb.String() 281 +} 282 + 283 +func buildTagValueHover(an *analyzer.Analysis, key, value string) string { 284 + var sb strings.Builder 285 + fmt.Fprintf(&sb, "**Tag:** `%s`", key) 286 + if value == "" { 287 + sb.WriteString("\n**Value:** *(empty)*") 288 + } else { 289 + fmt.Fprintf(&sb, "\n**Value:** `%s`", value) 290 + } 291 + if info := an.Tags[key]; info != nil { 292 + count := 0 293 + for _, u := range info.Usage { 294 + if u.Tag.Value == value { 295 + count++ 296 + } 297 + } 298 + fmt.Fprintf(&sb, "\n**Usage:** %d", count) 299 + } 300 + return sb.String() 301 +} 302 + 303 +func buildAmountHover(content string, am *ast.Amount, cost *ast.Cost) string { 304 + amountText := strings.TrimRight(content[am.Span.Start.Offset:am.Span.End.Offset], " \t") 305 + var sb strings.Builder 306 + fmt.Fprintf(&sb, "**Amount:** %s", amountText) 307 + if cost != nil { 308 + costText := strings.TrimRight(content[cost.Amount.Span.Start.Offset:cost.Amount.Span.End.Offset], " \t") 309 + label, marker := "**Unit cost:**", "@" 310 + if cost.IsTotal { 311 + label, marker = "**Total cost:**", "@@" 312 + } 313 + fmt.Fprintf(&sb, "\n\n%s %s %s", label, marker, costText) 314 + } 315 + return sb.String() 316 +} 317 + 318 +func buildDateHover(an *analyzer.Analysis, tx *ast.Transaction) string { 319 + var sb strings.Builder 320 + fmt.Fprintf(&sb, "**Date:** %04d-%02d-%02d", tx.Date.Year, tx.Date.Month, tx.Date.Day) 321 + if tx.Payee != nil { 322 + fmt.Fprintf(&sb, "\n\n**Payee:** %s", tx.Payee.Name) 323 + } 324 + fmt.Fprintf(&sb, "\n\n**Transactions:** %d", countTransactionsOnDate(an, tx.Date)) 325 + fmt.Fprintf(&sb, "\n\n**Postings:** %d", len(tx.Postings)) 326 + return sb.String() 327 +} 328 + 329 +func countTransactionsOnDate(an *analyzer.Analysis, d ast.Date) int { 330 + count := 0 331 + for _, tx := range an.Transactions { 332 + if tx.Date.Compare(d) == 0 { 333 + count++ 334 + } 335 + } 336 + return count 337 +} 338 + 339 +func writeDateSection(sb *strings.Builder, label string, d ast.Date) { 340 + if d.Year == 0 { 341 + return 342 + } 343 + fmt.Fprintf(sb, "\n%s %04d-%02d-%02d", label, d.Year, d.Month, d.Day) 344 +} 345 + 346 +func tagValueSpan(content string, t *ast.Tag) token.Span { 347 + start := tagKeySpan(content, t).End.Offset 348 + if start < t.Span.End.Offset && content[start] == ':' { 349 + start++ 350 + } 351 + end := t.Span.End.Offset 352 + for end > start && (content[end-1] == ' ' || content[end-1] == '\t') { 353 + end-- 354 + } 355 + return token.Span{Start: offsetPos(t.Span.Start.File, start), End: offsetPos(t.Span.Start.File, end)} 356 +}
A
internal/lsp/textdocument_hover_test.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "strings" 8 + "testing" 9 + "time" 10 + 11 + "go.lsp.dev/protocol" 12 + "go.lsp.dev/uri" 13 + 14 + "olexsmir.xyz/clerk/internal/analyzer" 15 + "olexsmir.xyz/clerk/internal/lsp/lsputil" 16 + "olexsmir.xyz/clerk/internal/testutil/golden" 17 + "olexsmir.xyz/clerk/journal" 18 + "olexsmir.xyz/clerk/journal/ast" 19 + "olexsmir.xyz/clerk/journal/token" 20 +) 21 + 22 +func TestTagValueSpan(t *testing.T) { 23 + tag := func(start, end int) *ast.Tag { 24 + return &ast.Tag{ 25 + Span: token.Span{Start: token.Pos{Offset: start}, End: token.Pos{Offset: end}}, 26 + } 27 + } 28 + 29 + tests := map[string]struct { 30 + content string 31 + tag *ast.Tag 32 + wantStart, wantEnd int 33 + }{ 34 + "value": {"; project:home", tag(2, 14), 10, 14}, 35 + "empty value": {"; done:", tag(2, 7), 7, 7}, 36 + "trailing space": {"; done: ", tag(2, 8), 7, 7}, 37 + "second of comma list": {"; a:1, b:2", tag(7, 10), 9, 10}, 38 + } 39 + for tname, tt := range tests { 40 + t.Run(tname, func(t *testing.T) { 41 + span := tagValueSpan(tt.content, tt.tag) 42 + if span.Start.Offset != tt.wantStart || span.End.Offset != tt.wantEnd { 43 + t.Errorf("got %d-%d, want %d-%d", span.Start.Offset, span.End.Offset, tt.wantStart, tt.wantEnd) 44 + } 45 + }) 46 + } 47 +} 48 + 49 +func TestCountTransactionsOnDate(t *testing.T) { 50 + tx := func(y, m, d int) *ast.Transaction { return &ast.Transaction{Date: ast.Date{Year: y, Month: m, Day: d}} } 51 + an := &analyzer.Analysis{Transactions: []*ast.Transaction{tx(2024, 1, 15), tx(2024, 1, 15), tx(2024, 2, 1)}} 52 + if got := countTransactionsOnDate(an, ast.Date{Year: 2024, Month: 1, Day: 15}); got != 2 { 53 + t.Errorf("got %d, want 2", got) 54 + } 55 + if got := countTransactionsOnDate(an, ast.Date{Year: 2025, Month: 1, Day: 15}); got != 0 { 56 + t.Errorf("got %d, want 0", got) 57 + } 58 +} 59 + 60 +func TestServerHover_DocumentNotFound(t *testing.T) { 61 + srv := NewServer("test") 62 + res, err := srv.server.Hover(context.Background(), &protocol.HoverParams{ 63 + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ 64 + TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")}, 65 + Position: protocol.Position{Line: 0, Character: 0}, 66 + }, 67 + }) 68 + if err != nil { 69 + t.Fatal(err) 70 + } 71 + if res != nil { 72 + t.Errorf("got %v, want nil", res) 73 + } 74 +} 75 + 76 +// Golden 77 + 78 +func TestHoverTxtar(t *testing.T) { 79 + for _, tt := range []string{"hover-journal", "hover-include"} { 80 + ar := golden.Read(t, tt) 81 + t.Run(tt, func(t *testing.T) { 82 + h := newTxtarHarness(t, ar) 83 + 84 + var b strings.Builder 85 + for i := range h.cursors { 86 + tdp := h.textDocumentPosition(i) 87 + res, err := h.srv.Hover(t.Context(), &protocol.HoverParams{ 88 + TextDocumentPositionParams: tdp, 89 + }) 90 + if err != nil { 91 + t.Fatal(err) 92 + } 93 + if res == nil { 94 + fmt.Fprintf(&b, "%d:%d <none>\n", tdp.Position.Line, tdp.Position.Character) 95 + continue 96 + } 97 + mc, ok := res.Contents.(*protocol.MarkupContent) 98 + if !ok { 99 + t.Fatalf("Hover returned %T, want *MarkupContent", res.Contents) 100 + } 101 + fmt.Fprintf(&b, "%d:%d %q %d:%d-%d:%d\n", 102 + tdp.Position.Line, tdp.Position.Character, mc.Value, 103 + res.Range.Start.Line, res.Range.Start.Character, res.Range.End.Line, res.Range.End.Character) 104 + } 105 + golden.Assert(t, ar, b.String()) 106 + }) 107 + } 108 +} 109 + 110 +func BenchmarkHover(b *testing.B) { 111 + path := "../../journal/testdata/journals/actual-1ktxns-100accts.journal" 112 + src, err := os.ReadFile(path) 113 + if err != nil { 114 + b.Fatal(err) 115 + } 116 + content := string(src) 117 + 118 + // Path must match the open doc: hoverAt resolves the document by path. 119 + an := analyzer.Build(journal.NewLoader().ResolveBytes("/test.journal", src)) 120 + 121 + srv := NewServer("test") 122 + srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal") 123 + srv.server.current = an 124 + 125 + for tname, tt := range map[string]int{ 126 + "date": strings.Index(content, "2000-01-01 transaction 1") + 3, 127 + "account": strings.Index(content, "\n 1:2:3 ") + len("\n ") + 2, 128 + "payee": strings.Index(content, "transaction 1") + len("transaction ") + 1, 129 + "amount with cost": strings.Index(content, "2 B @@") + 2, 130 + "commodity in cost": strings.Index(content, "@@ 2 C") + len("@@ 2 C") - 1, 131 + "late amount": strings.Index(content, "1000 L @") + 2, 132 + } { 133 + b.Run(tname, func(b *testing.B) { 134 + line, col := lsputil.LineCol(content, tt) 135 + params := &protocol.HoverParams{ 136 + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ 137 + TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")}, 138 + Position: protocol.Position{Line: uint32(line), Character: uint32(col)}, 139 + }, 140 + } 141 + 142 + // warm up 143 + if _, err := srv.server.Hover(b.Context(), params); err != nil { 144 + b.Fatal(err) 145 + } 146 + b.ReportAllocs() 147 + b.ResetTimer() 148 + for b.Loop() { 149 + if _, err := srv.server.Hover(b.Context(), params); err != nil { 150 + b.Fatal(err) 151 + } 152 + } 153 + 154 + // guard: hover walks entries from the top of the file, so a cursor 155 + // near the end costs ~0.8ms on this file; a whole-file re-parse per 156 + // request (~13ms) would blow past this and must be caught 157 + if avg := b.Elapsed() / time.Duration(b.N); avg > 5*time.Millisecond { 158 + b.Fatalf("hover %v/op: entry walk or reparse regression", avg) 159 + } 160 + }) 161 + } 162 +}