all repos

clerk @ ecdc664

missing tooling for ledger/hledger
3 files changed, 114 insertions(+), 100 deletions(-)
perf: lsp: index-driven rename edits

renameChanges rescanned every entry in every file to build the edit list
(650-750us on the 1ktxns fixture). Walk the analyzer index instead: the
Usages/Directives slices answer each kind, plus a new AliasDirectives
slice for top-level 'alias A = B' directives, the only entry class the
index did not cover. Reuses the open doc's cached line index for edit
ranges instead of rebuilding it per request (158KB string copy + 40KB
line table), and hoists per-usage source conversions in the tag paths.
Account edits keep byte-identical output; commodity renames now also
edit balance-assertion costs (= X @ Y), which the scan missed.
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-08-15 18:59:46 +0300
Authored at: 2026-08-14 12:36:36 +0300
Change ID: wlyqytwvtrvqzunpsmxqsypmoqrqsklz
Parent: c2e8832
M internal/analyzer/analyzer.go
···
        21
        21
         	// account "alias" subdirectives and top-level "alias A = B" directives.

      
        22
        22
         	AccountAliases map[string]string

      
        23
        23
         

      
        
        24
        +	// AliasDirectives holds every top-level "alias A = B" directive in entry order.

      
        
        25
        +	AliasDirectives []*ast.AliasDirective

      
        
        26
        +

      
        24
        27
         	AccountNames     []string            // sorted for binary search

      
        25
        28
         	PayeeNames       []string            // sorted, all payee names from directives + usage

      
        26
        29
         	AccountsByPrefix map[string][]string // "expenses:" -> ["expenses:food", "expenses:taxi"]

      
M internal/analyzer/build.go
···
        127
        127
         // source name resolves to the target account.

      
        128
        128
         func (a *Analysis) addAliasDirective(ad *ast.AliasDirective) {

      
        129
        129
         	a.AccountAliases[ad.From.String()] = ad.To.String()

      
        
        130
        +	a.AliasDirectives = append(a.AliasDirectives, ad)

      
        130
        131
         }

      
        131
        132
         

      
        132
        133
         func (a *Analysis) addPayeeDirective(pd *ast.PayeeDirective) {

      
M internal/lsp/textdocument_rename.go
···
        68
        68
         		return nil, nil

      
        69
        69
         	}

      
        70
        70
         

      
        71
        
        -	changes := renameChanges(an, ref, params.NewName)

      
        
        71
        +	changes := renameChanges(an, ref, params.NewName, state.lineIdx)

      
        72
        72
         	if len(changes) == 0 {

      
        73
        73
         		return nil, nil

      
        74
        74
         	}

      ···
        255
        255
         	return nil

      
        256
        256
         }

      
        257
        257
         

      
        258
        
        -// renameChanges collects the workspace edits renaming ref to newName

      
        259
        
        -func renameChanges(an *analyzer.Analysis, ref *symbolRef, newName string) map[uri.URI][]protocol.TextEdit {

      
        260
        
        -	changes := make(map[uri.URI][]protocol.TextEdit)

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

      
        262
        
        -		content := string(pf.Src)

      
        263
        
        -		// LineIndex is built lazily: most files have no matching edits, and

      
        264
        
        -		// each edit needs an O(log n) offset-to-position lookup, not a scan.

      
        265
        
        -		var li *lsputil.LineIndex

      
        266
        
        -		var edits []protocol.TextEdit

      
        267
        
        -		add := func(span token.Span, text string) {

      
        268
        
        -			if li == nil {

      
        269
        
        -				li = lsputil.NewLineIndex(content)

      
        
        258
        +func renameChanges(an *analyzer.Analysis, ref *symbolRef, newName string, primaryLI *lsputil.LineIndex) map[uri.URI][]protocol.TextEdit {

      
        
        259
        +	type fileEdits struct {

      
        
        260
        +		li    *lsputil.LineIndex

      
        
        261
        +		edits []protocol.TextEdit

      
        
        262
        +	}

      
        
        263
        +	files := make(map[int]*fileEdits)

      
        
        264
        +	add := func(fileIdx int, span token.Span, text string) {

      
        
        265
        +		fe := files[fileIdx]

      
        
        266
        +		if fe == nil {

      
        
        267
        +			fe = &fileEdits{}

      
        
        268
        +			if fileIdx == 0 {

      
        
        269
        +				fe.li = primaryLI

      
        
        270
        +			} else {

      
        
        271
        +				fe.li = lsputil.NewLineIndex(string(an.Files[fileIdx].Src))

      
        270
        272
         			}

      
        271
        
        -			edits = append(edits, protocol.TextEdit{

      
        272
        
        -				Range:   li.SpanRange(span),

      
        273
        
        -				NewText: text,

      
        274
        
        -			})

      
        
        273
        +			files[fileIdx] = fe

      
        275
        274
         		}

      
        276
        
        -		for _, e := range pf.Ast.Entries {

      
        277
        
        -			renameEntry(add, ref, newName, content, e)

      
        278
        
        -		}

      
        279
        
        -		if len(edits) > 0 {

      
        280
        
        -			changes[uri.File(pf.Path)] = edits

      
        281
        
        -		}

      
        
        275
        +		fe.edits = append(fe.edits, protocol.TextEdit{

      
        
        276
        +			Range:   fe.li.SpanRange(span),

      
        
        277
        +			NewText: text,

      
        
        278
        +		})

      
        
        279
        +	}

      
        
        280
        +

      
        
        281
        +	switch ref.kind {

      
        
        282
        +	case symbolAccount:

      
        
        283
        +		renameAccountEdits(an, ref, newName, add)

      
        
        284
        +	case symbolCommodity:

      
        
        285
        +		renameCommodityEdits(an, ref, newName, add)

      
        
        286
        +	case symbolPayee:

      
        
        287
        +		renamePayeeEdits(an, ref, newName, add)

      
        
        288
        +	case symbolTag:

      
        
        289
        +		renameTagEdits(an, ref, newName, add)

      
        
        290
        +	}

      
        
        291
        +

      
        
        292
        +	changes := make(map[uri.URI][]protocol.TextEdit, len(files))

      
        
        293
        +	for fileIdx, fe := range files {

      
        
        294
        +		changes[uri.File(an.Files[fileIdx].Path)] = fe.edits

      
        282
        295
         	}

      
        283
        296
         	sortAndDedup(changes)

      
        284
        297
         	return changes

      
        285
        298
         }

      
        286
        299
         

      
        287
        
        -func renameEntry(add func(token.Span, string), ref *symbolRef, newName, content string, e ast.Entry) {

      
        288
        
        -	switch e := e.(type) {

      
        289
        
        -	case *ast.Transaction:

      
        290
        
        -		renamePayee(add, ref, newName, e.Payee)

      
        291
        
        -		renameCommentTags(add, ref, newName, content, e.Comment)

      
        292
        
        -		for _, c := range e.HeaderComments {

      
        293
        
        -			renameCommentTags(add, ref, newName, content, c)

      
        294
        
        -		}

      
        295
        
        -		renamePostings(add, ref, newName, content, e.Postings)

      
        296
        
        -	case *ast.PeriodicTransaction:

      
        297
        
        -		renameCommentTags(add, ref, newName, content, e.Comment)

      
        298
        
        -		for _, c := range e.HeaderComments {

      
        299
        
        -			renameCommentTags(add, ref, newName, content, c)

      
        300
        
        -		}

      
        301
        
        -		renamePostings(add, ref, newName, content, e.Postings)

      
        302
        
        -	case *ast.AutomatedTransaction:

      
        303
        
        -		renameCommentTags(add, ref, newName, content, e.Comment)

      
        304
        
        -		for _, c := range e.HeaderComments {

      
        305
        
        -			renameCommentTags(add, ref, newName, content, c)

      
        
        300
        +func renameAccountEdits(an *analyzer.Analysis, ref *symbolRef, newName string, add func(int, token.Span, string)) {

      
        
        301
        +	for _, name := range an.AccountNames {

      
        
        302
        +		if !accountMatches(name, ref.name) {

      
        
        303
        +			continue

      
        306
        304
         		}

      
        307
        
        -		renamePostings(add, ref, newName, content, e.Postings)

      
        308
        
        -	case *ast.Comment:

      
        309
        
        -		renameCommentTags(add, ref, newName, content, e)

      
        310
        
        -	case *ast.AccountDirective:

      
        311
        
        -		if text, ok := ref.renameTo(symbolAccount, e.Account.String(), newName); ok {

      
        312
        
        -			add(e.Account.Span, text)

      
        
        305
        +		info := an.Accounts[name]

      
        
        306
        +		text := newName + strings.TrimPrefix(name, ref.name)

      
        
        307
        +		for _, u := range info.Usages {

      
        
        308
        +			add(u.FileIndex, u.Posting.Account.Span, text)

      
        313
        309
         		}

      
        314
        
        -		for _, sd := range e.Subdirectives {

      
        315
        
        -			if sd.Name == "alias" {

      
        316
        
        -				if text, ok := ref.renameTo(symbolAccount, sd.Value, newName); ok {

      
        317
        
        -					add(sd.ValueSpan, text)

      
        318
        
        -				}

      
        
        310
        +	}

      
        
        311
        +	for _, info := range an.Accounts {

      
        
        312
        +		for _, d := range info.Directives {

      
        
        313
        +			fileIdx := fileIndexForEntry(an, d)

      
        
        314
        +			if fileIdx < 0 {

      
        
        315
        +				continue

      
        319
        316
         			}

      
        320
        
        -		}

      
        321
        
        -	case *ast.AliasDirective:

      
        322
        
        -		if text, ok := ref.renameTo(symbolAccount, e.From.String(), newName); ok {

      
        323
        
        -			add(e.From.Span, text)

      
        324
        
        -		}

      
        325
        
        -		if text, ok := ref.renameTo(symbolAccount, e.To.String(), newName); ok {

      
        326
        
        -			add(e.To.Span, text)

      
        327
        
        -		}

      
        328
        
        -	case *ast.CommodityDirective:

      
        329
        
        -		if text, ok := ref.renameTo(symbolCommodity, e.Commodity, newName); ok {

      
        330
        
        -			add(e.CommoditySpan, text)

      
        331
        
        -		}

      
        332
        
        -	case *ast.PayeeDirective:

      
        333
        
        -		renamePayee(add, ref, newName, e.Name)

      
        334
        
        -	case *ast.TagDirective:

      
        335
        
        -		if text, ok := ref.renameTo(symbolTag, e.Name, newName); ok {

      
        336
        
        -			if span, ok := tagDirectiveSpan(content, e); ok {

      
        337
        
        -				add(span, text)

      
        
        317
        +			if accountMatches(d.Account.String(), ref.name) {

      
        
        318
        +				add(fileIdx, d.Account.Span, newName+strings.TrimPrefix(d.Account.String(), ref.name))

      
        
        319
        +			}

      
        
        320
        +			for _, sd := range d.Subdirectives {

      
        
        321
        +				if sd.Name == "alias" && accountMatches(sd.Value, ref.name) {

      
        
        322
        +					add(fileIdx, sd.ValueSpan, newName+strings.TrimPrefix(sd.Value, ref.name))

      
        
        323
        +				}

      
        338
        324
         			}

      
        339
        325
         		}

      
        340
        326
         	}

      
        341
        
        -}

      
        342
        
        -

      
        343
        
        -func renamePostings(add func(token.Span, string), ref *symbolRef, newName, content string, postings []*ast.Posting) {

      
        344
        
        -	for _, p := range postings {

      
        345
        
        -		if text, ok := ref.renameTo(symbolAccount, p.Account.String(), newName); ok {

      
        346
        
        -			add(p.Account.Span, text)

      
        
        327
        +	for _, ad := range an.AliasDirectives {

      
        
        328
        +		fileIdx := fileIndexForEntry(an, ad)

      
        
        329
        +		if fileIdx < 0 {

      
        
        330
        +			continue

      
        347
        331
         		}

      
        348
        
        -		renameCommodity(add, ref, newName, p.Amount)

      
        349
        
        -		if p.Cost != nil {

      
        350
        
        -			renameCommodity(add, ref, newName, &p.Cost.Amount)

      
        351
        
        -		}

      
        352
        
        -		if p.Balance != nil {

      
        353
        
        -			renameCommodity(add, ref, newName, &p.Balance.Amount)

      
        
        332
        +		if accountMatches(ad.From.String(), ref.name) {

      
        
        333
        +			add(fileIdx, ad.From.Span, newName+strings.TrimPrefix(ad.From.String(), ref.name))

      
        354
        334
         		}

      
        355
        
        -		renameCommentTags(add, ref, newName, content, p.Comment)

      
        356
        
        -		for i := range p.Comments {

      
        357
        
        -			renameCommentTags(add, ref, newName, content, &p.Comments[i])

      
        
        335
        +		if accountMatches(ad.To.String(), ref.name) {

      
        
        336
        +			add(fileIdx, ad.To.Span, newName+strings.TrimPrefix(ad.To.String(), ref.name))

      
        358
        337
         		}

      
        359
        338
         	}

      
        360
        339
         }

      
        361
        340
         

      
        362
        
        -func renameCommodity(add func(token.Span, string), ref *symbolRef, newName string, am *ast.Amount) {

      
        363
        
        -	if am == nil {

      
        
        341
        +func renameCommodityEdits(an *analyzer.Analysis, ref *symbolRef, newName string, add func(int, token.Span, string)) {

      
        
        342
        +	info := an.Commodities[ref.name]

      
        
        343
        +	if info == nil {

      
        364
        344
         		return

      
        365
        345
         	}

      
        366
        
        -	if text, ok := ref.renameTo(symbolCommodity, am.Commodity, newName); ok {

      
        367
        
        -		add(am.CommoditySpan, text)

      
        
        346
        +	for _, d := range info.Directives {

      
        
        347
        +		if fileIdx := fileIndexForEntry(an, d); fileIdx >= 0 {

      
        
        348
        +			add(fileIdx, d.CommoditySpan, newName)

      
        
        349
        +		}

      
        
        350
        +	}

      
        
        351
        +	for _, u := range info.Usages {

      
        
        352
        +		add(u.FileIndex, u.Amount.CommoditySpan, newName)

      
        368
        353
         	}

      
        369
        354
         }

      
        370
        355
         

      
        371
        
        -func renamePayee(add func(token.Span, string), ref *symbolRef, newName string, p *ast.Payee) {

      
        372
        
        -	if p == nil {

      
        
        356
        +func renamePayeeEdits(an *analyzer.Analysis, ref *symbolRef, newName string, add func(int, token.Span, string)) {

      
        
        357
        +	info := an.Payees[ref.name]

      
        
        358
        +	if info == nil {

      
        373
        359
         		return

      
        374
        360
         	}

      
        375
        
        -	if text, ok := ref.renameTo(symbolPayee, p.Name, newName); ok {

      
        376
        
        -		add(p.Span, text)

      
        
        361
        +	for _, d := range info.Directives {

      
        
        362
        +		if d.Name != nil {

      
        
        363
        +			if fileIdx := fileIndexForEntry(an, d); fileIdx >= 0 {

      
        
        364
        +				add(fileIdx, d.Name.Span, newName)

      
        
        365
        +			}

      
        
        366
        +		}

      
        
        367
        +	}

      
        
        368
        +	for _, u := range info.Usage {

      
        
        369
        +		add(u.FileIndex, u.Payee.Span, newName)

      
        377
        370
         	}

      
        378
        371
         }

      
        379
        372
         

      
        380
        
        -func renameCommentTags(add func(token.Span, string), ref *symbolRef, newName, content string, c *ast.Comment) {

      
        381
        
        -	if c == nil {

      
        
        373
        +func renameTagEdits(an *analyzer.Analysis, ref *symbolRef, newName string, add func(int, token.Span, string)) {

      
        
        374
        +	info := an.Tags[ref.name]

      
        
        375
        +	if info == nil {

      
        382
        376
         		return

      
        383
        377
         	}

      
        384
        
        -	for i := range c.Tags {

      
        385
        
        -		t := &c.Tags[i]

      
        386
        
        -		if text, ok := ref.renameTo(symbolTag, t.Key, newName); ok {

      
        387
        
        -			add(tagKeySpan(content, t), text)

      
        
        378
        +	contents := make(map[int]string) // file index → source, converted once per file

      
        
        379
        +	content := func(fileIdx int) string {

      
        
        380
        +		s, ok := contents[fileIdx]

      
        
        381
        +		if !ok {

      
        
        382
        +			s = string(an.Files[fileIdx].Src)

      
        
        383
        +			contents[fileIdx] = s

      
        388
        384
         		}

      
        
        385
        +		return s

      
        
        386
        +	}

      
        
        387
        +	for _, d := range info.Directives {

      
        
        388
        +		fileIdx := fileIndexForEntry(an, d)

      
        
        389
        +		if fileIdx < 0 {

      
        
        390
        +			continue

      
        
        391
        +		}

      
        
        392
        +		if span, ok := tagDirectiveSpan(content(fileIdx), d); ok {

      
        
        393
        +			add(fileIdx, span, newName)

      
        
        394
        +		}

      
        
        395
        +	}

      
        
        396
        +	for _, u := range info.Usage {

      
        
        397
        +		span := tagKeySpan(content(u.FileIndex), u.Tag)

      
        
        398
        +		add(u.FileIndex, span, newName)

      
        389
        399
         	}

      
        390
        400
         }

      
        391
        401