all repos

clerk @ 1efa5a1

missing tooling for ledger/hledger
10 files changed, 782 insertions(+), 135 deletions(-)
lsp: rename
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-08-12 13:28:05 +0300
Authored at: 2026-08-10 16:23:27 +0300
Change ID: yqykprmkrmsxusrtzwmrxykmyorskkvr
Parent: c027d7c
M internal/lsp/server.go
···
        72
        72
         		Capabilities: protocol.ServerCapabilities{

      
        73
        73
         			DocumentFormattingProvider: &protocol.DocumentFormattingOptions{},

      
        74
        74
         			DefinitionProvider:         protocol.Boolean(true),

      
        
        75
        +			RenameProvider: &protocol.RenameOptions{

      
        
        76
        +				PrepareProvider: new(true),

      
        
        77
        +			},

      
        75
        78
         			CompletionProvider: &protocol.CompletionOptions{

      
        76
        79
         				TriggerCharacters: []string{":", "@"},

      
        77
        80
         			},

      
A internal/lsp/testdata/rename-account.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +include sub.journal

      
        
        3
        +

      
        
        4
        +account ex^penses

      
        
        5
        +account expenses2

      
        
        6
        +account expenses:food

      
        
        7
        +

      
        
        8
        +2024-01-15 Grocery Store

      
        
        9
        +    expenses:food  $50

      
        
        10
        +    expenses2  $20

      
        
        11
        +    (expenses:food)  $10

      
        
        12
        +

      
        
        13
        +2024-01-16 Another Store

      
        
        14
        +    expen^ses:food  $30

      
        
        15
        +    assets:cash

      
        
        16
        +

      
        
        17
        +-- sub.journal --

      
        
        18
        +account expenses:food

      
        
        19
        +

      
        
        20
        +2024-01-16 Grocery Store

      
        
        21
        +    expenses:food  $5

      
        
        22
        +

      
        
        23
        +-- rename --

      
        
        24
        +costs

      
        
        25
        +food

      
        
        26
        +

      
        
        27
        +-- expect --

      
        
        28
        +2:10

      
        
        29
        +  prep in.journal 2:8-2:16 "expenses"

      
        
        30
        +  ren in.journal 2:8-2:16 "costs"

      
        
        31
        +  ren in.journal 4:8-4:21 "costs:food"

      
        
        32
        +  ren in.journal 7:4-7:17 "costs:food"

      
        
        33
        +  ren in.journal 9:5-9:18 "costs:food"

      
        
        34
        +  ren in.journal 12:4-12:17 "costs:food"

      
        
        35
        +  ren sub.journal 0:8-0:21 "costs:food"

      
        
        36
        +  ren sub.journal 3:4-3:17 "costs:food"

      
        
        37
        +12:9

      
        
        38
        +  prep in.journal 12:4-12:17 "expenses:food"

      
        
        39
        +  ren in.journal 4:8-4:21 "food"

      
        
        40
        +  ren in.journal 7:4-7:17 "food"

      
        
        41
        +  ren in.journal 9:5-9:18 "food"

      
        
        42
        +  ren in.journal 12:4-12:17 "food"

      
        
        43
        +  ren sub.journal 0:8-0:21 "food"

      
        
        44
        +  ren sub.journal 3:4-3:17 "food"

      
A internal/lsp/testdata/rename-commodity.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +commodity $

      
        
        3
        +    format 1,000.00

      
        
        4
        +

      
        
        5
        +commodity EUR

      
        
        6
        +    format 1.000,00 EUR

      
        
        7
        +

      
        
        8
        +2024-01-15 Grocery Store

      
        
        9
        +    expenses:food  ^$50

      
        
        10
        +    assets:cash  100.00 EUR

      
        
        11
        +

      
        
        12
        +2024-01-16 Another Store

      
        
        13
        +    expenses:food  50 E^UR

      
        
        14
        +    assets:cash  $30 @ ^$1.5

      
        
        15
        +    assets:checking  = ^$40

      
        
        16
        +

      
        
        17
        +-- rename --

      
        
        18
        +€

      
        
        19
        +USD

      
        
        20
        +€

      
        
        21
        +€

      
        
        22
        +

      
        
        23
        +-- expect --

      
        
        24
        +7:19

      
        
        25
        +  prep in.journal 7:19-7:20 "$"

      
        
        26
        +  ren in.journal 0:10-0:11 "€"

      
        
        27
        +  ren in.journal 7:19-7:20 "€"

      
        
        28
        +  ren in.journal 12:17-12:18 "€"

      
        
        29
        +  ren in.journal 12:23-12:24 "€"

      
        
        30
        +  ren in.journal 13:23-13:24 "€"

      
        
        31
        +11:23

      
        
        32
        +  prep in.journal 11:22-11:25 "EUR"

      
        
        33
        +  ren in.journal 3:10-3:13 "USD"

      
        
        34
        +  ren in.journal 8:24-8:27 "USD"

      
        
        35
        +  ren in.journal 11:22-11:25 "USD"

      
        
        36
        +12:23

      
        
        37
        +  prep in.journal 12:23-12:24 "$"

      
        
        38
        +  ren in.journal 0:10-0:11 "€"

      
        
        39
        +  ren in.journal 7:19-7:20 "€"

      
        
        40
        +  ren in.journal 12:17-12:18 "€"

      
        
        41
        +  ren in.journal 12:23-12:24 "€"

      
        
        42
        +  ren in.journal 13:23-13:24 "€"

      
        
        43
        +13:23

      
        
        44
        +  prep in.journal 13:23-13:24 "$"

      
        
        45
        +  ren in.journal 0:10-0:11 "€"

      
        
        46
        +  ren in.journal 7:19-7:20 "€"

      
        
        47
        +  ren in.journal 12:17-12:18 "€"

      
        
        48
        +  ren in.journal 12:23-12:24 "€"

      
        
        49
        +  ren in.journal 13:23-13:24 "€"

      
A internal/lsp/testdata/rename-payee.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +payee Grocery Store

      
        
        3
        +

      
        
        4
        +2024-01-15 Grocery^ Store

      
        
        5
        +    expenses:food  $50

      
        
        6
        +

      
        
        7
        +2024-01-16 "Grocery Store"

      
        
        8
        +    expenses:food  $30

      
        
        9
        +

      
        
        10
        +^2024-01-17 продукти

      
        
        11
        +    expenses:food  $10

      
        
        12
        +

      
        
        13
        +2024-01-18 прод^укти

      
        
        14
        +    expenses:food  $20

      
        
        15
        +

      
        
        16
        +-- rename --

      
        
        17
        +Acme

      
        
        18
        +Acme

      
        
        19
        +products

      
        
        20
        +

      
        
        21
        +-- expect --

      
        
        22
        +2:18

      
        
        23
        +  prep in.journal 2:11-2:24 "Grocery Store"

      
        
        24
        +  ren in.journal 0:6-0:19 "Acme"

      
        
        25
        +  ren in.journal 2:11-2:24 "Acme"

      
        
        26
        +  ren in.journal 5:11-5:26 "Acme"

      
        
        27
        +8:0

      
        
        28
        +  prep <none>

      
        
        29
        +  ren <none>

      
        
        30
        +11:15

      
        
        31
        +  prep in.journal 11:11-11:19 "продукти"

      
        
        32
        +  ren in.journal 8:11-8:19 "products"

      
        
        33
        +  ren in.journal 11:11-11:19 "products"

      
A internal/lsp/testdata/rename-tag.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +2024-01-15 Grocery Store ; cli^ent:acme, urgent

      
        
        3
        +    expenses:food  $50 ; project:alpha

      
        
        4
        +

      
        
        5
        +2024-01-16 Grocery Store

      
        
        6
        +    ; client:other

      
        
        7
        +    expenses:food  $30 ; client:beta, url:https://x/y

      
        
        8
        +

      
        
        9
        +tag cli^ent

      
        
        10
        +

      
        
        11
        +2024-01-17 Grocery Store ; ur^gent, url:https://x^/y

      
        
        12
        +    expenses:food  $10 ; pro^ject:beta

      
        
        13
        +

      
        
        14
        +-- rename --

      
        
        15
        +customer

      
        
        16
        +customer

      
        
        17
        +priority

      
        
        18
        +ignored

      
        
        19
        +task

      
        
        20
        +

      
        
        21
        +-- expect --

      
        
        22
        +0:30

      
        
        23
        +  prep in.journal 0:27-0:33 "client"

      
        
        24
        +  ren in.journal 0:27-0:33 "customer"

      
        
        25
        +  ren in.journal 4:6-4:12 "customer"

      
        
        26
        +  ren in.journal 5:25-5:31 "customer"

      
        
        27
        +  ren in.journal 7:4-7:10 "customer"

      
        
        28
        +7:7

      
        
        29
        +  prep <none>

      
        
        30
        +  ren <none>

      
        
        31
        +9:29

      
        
        32
        +  prep <none>

      
        
        33
        +  ren <none>

      
        
        34
        +9:48

      
        
        35
        +  prep <none>

      
        
        36
        +  ren <none>

      
        
        37
        +10:28

      
        
        38
        +  prep in.journal 10:25-10:32 "project"

      
        
        39
        +  ren in.journal 1:25-1:32 "task"

      
        
        40
        +  ren in.journal 10:25-10:32 "task"

      
M internal/lsp/textdocument_completion_test.go
···
        162
        162
         // Golden

      
        163
        163
         

      
        164
        164
         func TestCompletionTxtar(t *testing.T) {

      
        165
        
        -	tests := []string{

      
        166
        
        -		"completion-journal",

      
        167
        
        -		"completion-unicode",

      
        168
        
        -		"completion-crlf",

      
        169
        
        -	}

      
        170
        
        -

      
        171
        
        -	for _, tt := range tests {

      
        
        165
        +	for _, tt := range []string{"completion-journal", "completion-unicode", "completion-crlf"} {

      
        172
        166
         		ar := golden.Read(t, tt)

      
        173
        167
         

      
        174
        168
         		t.Run(tt, func(t *testing.T) {

      
        175
        
        -			content := string(ar.Get("in.journal"))

      
        176
        
        -

      
        177
        
        -			var cursors []int

      
        178
        
        -			for {

      
        179
        
        -				m := strings.Index(content, "^")

      
        180
        
        -				if m < 0 {

      
        181
        
        -					break

      
        182
        
        -				}

      
        183
        
        -				cursors = append(cursors, m)

      
        184
        
        -				content = content[:m] + content[m+1:]

      
        185
        
        -			}

      
        186
        
        -			if len(cursors) == 0 {

      
        187
        
        -				t.Fatal("no '^' markers in in.journal")

      
        188
        
        -			}

      
        189
        
        -

      
        190
        
        -			srv := NewServer("test")

      
        191
        
        -			srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")

      
        192
        
        -			srv.server.current = analyzer.Build(srv.server.loader.ResolveBytes("", []byte(content)))

      
        
        169
        +			h := newTxtarHarness(t, ar)

      
        193
        170
         

      
        194
        171
         			var b strings.Builder

      
        195
        
        -			for _, c := range cursors {

      
        196
        
        -				line, col := lsputil.LineCol(content, c)

      
        197
        
        -				res, err := srv.server.Completion(t.Context(), &protocol.CompletionParams{

      
        198
        
        -					TextDocumentPositionParams: protocol.TextDocumentPositionParams{

      
        199
        
        -						TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")},

      
        200
        
        -						Position:     protocol.Position{Line: uint32(line), Character: uint32(col)},

      
        201
        
        -					},

      
        
        172
        +			for i, c := range h.cursors {

      
        
        173
        +				tdp := h.textDocumentPosition(i)

      
        
        174
        +				res, err := h.srv.Completion(t.Context(), &protocol.CompletionParams{

      
        
        175
        +					TextDocumentPositionParams: tdp,

      
        202
        176
         				})

      
        203
        177
         				if err != nil {

      
        204
        178
         					t.Fatal(err)

      
        205
        179
         				}

      
        206
        
        -				ctx, start := detectCompletionCtx(content, c)

      
        207
        
        -				fmt.Fprintf(&b, "%d:%d %s %q\n", line, col, ctx, content[start:c])

      
        
        180
        +				ctx, start := detectCompletionCtx(h.content, c)

      
        
        181
        +				fmt.Fprintf(&b, "%d:%d %s %q\n", tdp.Position.Line, tdp.Position.Character, ctx, h.content[start:c])

      
        208
        182
         				list, ok := res.(*protocol.CompletionList)

      
        209
        183
         				if !ok {

      
        210
        184
         					t.Fatalf("Completion returned %T, want *protocol.CompletionList", res)

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

      
        26
        26
         

      
        27
        27
         func findDefinitionUnderCursor(an *analyzer.Analysis, docPath, content string, cursor int) protocol.LocationSlice {

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

      
        29
        
        -		if pf.Path != docPath {

      
        30
        
        -			continue

      
        31
        
        -		}

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

      
        33
        
        -			if loc := definitionInEntry(an, content, entry, cursor); loc != nil {

      
        34
        
        -				return protocol.LocationSlice{*loc}

      
        35
        
        -			}

      
        36
        
        -		}

      
        
        28
        +	ref := findSymbolUnderCursor(an, docPath, content, cursor)

      
        
        29
        +	if ref == nil {

      
        37
        30
         		return nil

      
        38
        31
         	}

      
        39
        
        -	return nil

      
        40
        
        -}

      
        41
        
        -

      
        42
        
        -func definitionInEntry(an *analyzer.Analysis, content string, e ast.Entry, cursor int) *protocol.Location {

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

      
        44
        
        -	case *ast.Transaction:

      
        45
        
        -		if e.Payee != nil && spanContains(content, e.Payee.Span, cursor) {

      
        46
        
        -			return findPayeeDefinition(an, e.Payee.Name)

      
        47
        
        -		}

      
        48
        
        -		return definitionInPostings(an, content, e.Postings, cursor)

      
        49
        
        -	case *ast.PeriodicTransaction:

      
        50
        
        -		return definitionInPostings(an, content, e.Postings, cursor)

      
        51
        
        -	case *ast.AutomatedTransaction:

      
        52
        
        -		return definitionInPostings(an, content, e.Postings, cursor)

      
        53
        
        -	case *ast.AccountDirective:

      
        54
        
        -		if spanContains(content, e.Account.Span, cursor) {

      
        55
        
        -			return findAccountDefinition(an, e.Account.String())

      
        56
        
        -		}

      
        57
        
        -	case *ast.CommodityDirective:

      
        58
        
        -		if spanContains(content, e.CommoditySpan, cursor) {

      
        59
        
        -			return findCommodityDefinition(an, e.Commodity)

      
        60
        
        -		}

      
        61
        
        -	case *ast.PayeeDirective:

      
        62
        
        -		if e.Name != nil && spanContains(content, e.Name.Span, cursor) {

      
        63
        
        -			return findPayeeDefinition(an, e.Name.Name)

      
        64
        
        -		}

      
        
        32
        +	loc := resolveSymbol(ref, an)

      
        
        33
        +	if loc == nil {

      
        
        34
        +		return nil

      
        65
        35
         	}

      
        66
        
        -	return nil

      
        
        36
        +	return protocol.LocationSlice{*loc}

      
        67
        37
         }

      
        68
        38
         

      
        69
        
        -func definitionInPostings(an *analyzer.Analysis, content string, postings []*ast.Posting, cursor int) *protocol.Location {

      
        70
        
        -	for _, p := range postings {

      
        71
        
        -		if spanContains(content, p.Account.Span, cursor) {

      
        72
        
        -			return findAccountDefinition(an, p.Account.String())

      
        73
        
        -		}

      
        74
        
        -		if loc := commodityDefinition(an, content, p.Amount, cursor); loc != nil {

      
        75
        
        -			return loc

      
        76
        
        -		}

      
        77
        
        -		if p.Cost != nil {

      
        78
        
        -			if loc := commodityDefinition(an, content, &p.Cost.Amount, cursor); loc != nil {

      
        79
        
        -				return loc

      
        80
        
        -			}

      
        81
        
        -		}

      
        82
        
        -		if p.Balance != nil {

      
        83
        
        -			if loc := commodityDefinition(an, content, &p.Balance.Amount, cursor); loc != nil {

      
        84
        
        -				return loc

      
        85
        
        -			}

      
        86
        
        -		}

      
        
        39
        +func resolveSymbol(ref *symbolRef, an *analyzer.Analysis) *protocol.Location {

      
        
        40
        +	switch ref.kind {

      
        
        41
        +	case symbolAccount:

      
        
        42
        +		return findAccountDefinition(an, ref.name)

      
        
        43
        +	case symbolCommodity:

      
        
        44
        +		return findCommodityDefinition(an, ref.name)

      
        
        45
        +	case symbolPayee:

      
        
        46
        +		return findPayeeDefinition(an, ref.name)

      
        87
        47
         	}

      
        88
        48
         	return nil

      
        89
        
        -}

      
        90
        
        -

      
        91
        
        -func commodityDefinition(an *analyzer.Analysis, content string, am *ast.Amount, cursor int) *protocol.Location {

      
        92
        
        -	if am == nil || am.Commodity == "" || !spanContains(content, am.CommoditySpan, cursor) {

      
        93
        
        -		return nil

      
        94
        
        -	}

      
        95
        
        -	return findCommodityDefinition(an, am.Commodity)

      
        96
        49
         }

      
        97
        50
         

      
        98
        51
         func findAccountDefinition(an *analyzer.Analysis, name string) *protocol.Location {

      
M internal/lsp/textdocument_definition_test.go
···
        2
        2
         

      
        3
        3
         import (

      
        4
        4
         	"fmt"

      
        5
        
        -	"os"

      
        6
        5
         	"path/filepath"

      
        7
        6
         	"strings"

      
        8
        7
         	"testing"

      ···
        10
        9
         	"go.lsp.dev/protocol"

      
        11
        10
         	"go.lsp.dev/uri"

      
        12
        11
         

      
        13
        
        -	"olexsmir.xyz/clerk/internal/lsp/lsputil"

      
        14
        12
         	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        15
        13
         )

      
        16
        14
         

      ···
        33
        31
         // Golden

      
        34
        32
         

      
        35
        33
         func TestDefinitionTxtar(t *testing.T) {

      
        36
        
        -	tests := []string{

      
        37
        
        -		"definition-journal",

      
        38
        
        -		"definition-include",

      
        39
        
        -	}

      
        40
        
        -	for _, tt := range tests {

      
        
        34
        +	for _, tt := range []string{"definition-journal", "definition-include"} {

      
        41
        35
         		ar := golden.Read(t, tt)

      
        42
        36
         		t.Run(tt, func(t *testing.T) {

      
        43
        
        -			dir := t.TempDir()

      
        44
        
        -			for _, f := range ar.Files {

      
        45
        
        -				if f.Name != "expect" {

      
        46
        
        -					if err := os.WriteFile(filepath.Join(dir, f.Name), f.Data, 0o644); err != nil {

      
        47
        
        -						t.Fatal(err)

      
        48
        
        -					}

      
        49
        
        -				}

      
        50
        
        -			}

      
        51
        
        -

      
        52
        
        -			content, cursors := stripCursors(string(ar.Get("in.journal")))

      
        53
        
        -			u := uri.File(filepath.Join(dir, "in.journal"))

      
        54
        
        -

      
        55
        
        -			srv := NewServer("test")

      
        56
        
        -			srv.server.openDoc(u, content, 1, "journal")

      
        
        37
        +			h := newTxtarHarness(t, ar)

      
        57
        38
         

      
        58
        39
         			var b strings.Builder

      
        59
        
        -			for _, c := range cursors {

      
        60
        
        -				pos := lsputil.Position(content, c)

      
        61
        
        -				res, err := srv.server.Definition(t.Context(), &protocol.DefinitionParams{

      
        62
        
        -					TextDocumentPositionParams: protocol.TextDocumentPositionParams{

      
        63
        
        -						TextDocument: protocol.TextDocumentIdentifier{URI: u},

      
        64
        
        -						Position:     pos,

      
        65
        
        -					},

      
        
        40
        +			for i := range h.cursors {

      
        
        41
        +				pos := h.textDocumentPosition(i).Position

      
        
        42
        +				res, err := h.srv.Definition(t.Context(), &protocol.DefinitionParams{

      
        
        43
        +					TextDocumentPositionParams: h.textDocumentPosition(i),

      
        66
        44
         				})

      
        67
        45
         				if err != nil {

      
        68
        46
         					t.Fatal(err)

      ···
        81
        59
         		})

      
        82
        60
         	}

      
        83
        61
         }

      
        84
        
        -

      
        85
        
        -func stripCursors(content string) (string, []int) {

      
        86
        
        -	var cursors []int

      
        87
        
        -	for {

      
        88
        
        -		i := strings.Index(content, "^")

      
        89
        
        -		if i < 0 {

      
        90
        
        -			return content, cursors

      
        91
        
        -		}

      
        92
        
        -		cursors = append(cursors, i)

      
        93
        
        -		content = content[:i] + content[i+1:]

      
        94
        
        -	}

      
        95
        
        -}

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"fmt"

      
        
        6
        +	"sort"

      
        
        7
        +	"strings"

      
        
        8
        +

      
        
        9
        +	"go.lsp.dev/protocol"

      
        
        10
        +	"go.lsp.dev/uri"

      
        
        11
        +

      
        
        12
        +	"olexsmir.xyz/clerk/internal/analyzer"

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

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

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

      
        
        16
        +)

      
        
        17
        +

      
        
        18
        +func (s *server) PrepareRename(_ context.Context, params *protocol.PrepareRenameParams) (protocol.PrepareRenameResult, error) {

      
        
        19
        +	state, ok := s.getDocState(params.TextDocument.URI)

      
        
        20
        +	if !ok {

      
        
        21
        +		return nil, nil

      
        
        22
        +	}

      
        
        23
        +

      
        
        24
        +	an := s.analysis()

      
        
        25
        +	cursor := lsputil.Offset(state.text, int(params.Position.Line), int(params.Position.Character))

      
        
        26
        +	ref := findSymbolUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor)

      
        
        27
        +	if ref == nil {

      
        
        28
        +		return nil, nil

      
        
        29
        +	}

      
        
        30
        +

      
        
        31
        +	return &protocol.PrepareRenamePlaceholder{

      
        
        32
        +		Range:       spanToProtocolRange(state.text, ref.span),

      
        
        33
        +		Placeholder: ref.name,

      
        
        34
        +	}, nil

      
        
        35
        +}

      
        
        36
        +

      
        
        37
        +func (s *server) Rename(_ context.Context, params *protocol.RenameParams) (*protocol.WorkspaceEdit, error) {

      
        
        38
        +	state, ok := s.getDocState(params.TextDocument.URI)

      
        
        39
        +	if !ok {

      
        
        40
        +		return nil, nil

      
        
        41
        +	}

      
        
        42
        +

      
        
        43
        +	an := s.analysis()

      
        
        44
        +	cursor := lsputil.Offset(state.text, int(params.Position.Line), int(params.Position.Character))

      
        
        45
        +	ref := findSymbolUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor)

      
        
        46
        +	if ref == nil {

      
        
        47
        +		return nil, nil

      
        
        48
        +	}

      
        
        49
        +

      
        
        50
        +	switch ref.kind {

      
        
        51
        +	case symbolAccount:

      
        
        52
        +		if err := validateAccountName(params.NewName); err != nil {

      
        
        53
        +			return nil, err

      
        
        54
        +		}

      
        
        55
        +	case symbolCommodity:

      
        
        56
        +		if err := validateCommodityName(params.NewName); err != nil {

      
        
        57
        +			return nil, err

      
        
        58
        +		}

      
        
        59
        +	case symbolPayee:

      
        
        60
        +		if err := validatePayeeName(params.NewName); err != nil {

      
        
        61
        +			return nil, err

      
        
        62
        +		}

      
        
        63
        +	case symbolTag:

      
        
        64
        +		if err := validateTagName(params.NewName); err != nil {

      
        
        65
        +			return nil, err

      
        
        66
        +		}

      
        
        67
        +	default:

      
        
        68
        +		return nil, nil

      
        
        69
        +	}

      
        
        70
        +

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

      
        
        72
        +	if len(changes) == 0 {

      
        
        73
        +		return nil, nil

      
        
        74
        +	}

      
        
        75
        +	return &protocol.WorkspaceEdit{Changes: changes}, nil

      
        
        76
        +}

      
        
        77
        +

      
        
        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
        +		for _, entry := range pf.Ast.Entries {

      
        
        84
        +			if ref := symbolInEntry(content, entry, cursor); ref != nil {

      
        
        85
        +				return ref

      
        
        86
        +			}

      
        
        87
        +		}

      
        
        88
        +		return nil

      
        
        89
        +	}

      
        
        90
        +	return nil

      
        
        91
        +}

      
        
        92
        +

      
        
        93
        +func symbolInEntry(content string, e ast.Entry, cursor int) *symbolRef {

      
        
        94
        +	switch e := e.(type) {

      
        
        95
        +	case *ast.Transaction:

      
        
        96
        +		if e.Payee != nil && spanContains(content, e.Payee.Span, cursor) {

      
        
        97
        +			return &symbolRef{symbolPayee, e.Payee.Name, e.Payee.Span}

      
        
        98
        +		}

      
        
        99
        +		if ref := tagRefInComment(content, e.Comment, cursor); ref != nil {

      
        
        100
        +			return ref

      
        
        101
        +		}

      
        
        102
        +		for _, c := range e.HeaderComments {

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

      
        
        104
        +				return ref

      
        
        105
        +			}

      
        
        106
        +		}

      
        
        107
        +		return symbolInPostings(content, e.Postings, cursor)

      
        
        108
        +	case *ast.PeriodicTransaction:

      
        
        109
        +		if ref := tagRefInComment(content, e.Comment, cursor); ref != nil {

      
        
        110
        +			return ref

      
        
        111
        +		}

      
        
        112
        +		for _, c := range e.HeaderComments {

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

      
        
        114
        +				return ref

      
        
        115
        +			}

      
        
        116
        +		}

      
        
        117
        +		return symbolInPostings(content, e.Postings, cursor)

      
        
        118
        +	case *ast.AutomatedTransaction:

      
        
        119
        +		if ref := tagRefInComment(content, e.Comment, cursor); ref != nil {

      
        
        120
        +			return ref

      
        
        121
        +		}

      
        
        122
        +		for _, c := range e.HeaderComments {

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

      
        
        124
        +				return ref

      
        
        125
        +			}

      
        
        126
        +		}

      
        
        127
        +		return symbolInPostings(content, e.Postings, cursor)

      
        
        128
        +	case *ast.Comment:

      
        
        129
        +		return tagRefInComment(content, e, cursor)

      
        
        130
        +	case *ast.AccountDirective:

      
        
        131
        +		if spanContains(content, e.Account.Span, cursor) {

      
        
        132
        +			return &symbolRef{symbolAccount, e.Account.String(), e.Account.Span}

      
        
        133
        +		}

      
        
        134
        +	case *ast.CommodityDirective:

      
        
        135
        +		if spanContains(content, e.CommoditySpan, cursor) {

      
        
        136
        +			return &symbolRef{symbolCommodity, e.Commodity, e.CommoditySpan}

      
        
        137
        +		}

      
        
        138
        +	case *ast.PayeeDirective:

      
        
        139
        +		if e.Name != nil && spanContains(content, e.Name.Span, cursor) {

      
        
        140
        +			return &symbolRef{symbolPayee, e.Name.Name, e.Name.Span}

      
        
        141
        +		}

      
        
        142
        +	}

      
        
        143
        +	return nil

      
        
        144
        +}

      
        
        145
        +

      
        
        146
        +// symbolKind is the kind of symbol under the cursor.

      
        
        147
        +type symbolKind int

      
        
        148
        +

      
        
        149
        +const (

      
        
        150
        +	symbolAccount symbolKind = iota

      
        
        151
        +	symbolCommodity

      
        
        152
        +	symbolPayee

      
        
        153
        +	symbolTag

      
        
        154
        +)

      
        
        155
        +

      
        
        156
        +// symbolRef is a symbol under the cursor, ready to be resolved or renamed.

      
        
        157
        +type symbolRef struct {

      
        
        158
        +	kind symbolKind

      
        
        159
        +	name string

      
        
        160
        +	span token.Span

      
        
        161
        +}

      
        
        162
        +

      
        
        163
        +// renameTo returns the replacement rext for an occurrence of the nodeKind with the given name.

      
        
        164
        +func (ref *symbolRef) renameTo(nodeKind symbolKind, name, newName string) (text string, renamed bool) {

      
        
        165
        +	if ref.kind != nodeKind {

      
        
        166
        +		return "", false

      
        
        167
        +	}

      
        
        168
        +	if ref.kind == symbolAccount {

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

      
        
        170
        +			return "", false

      
        
        171
        +		}

      
        
        172
        +		return newName + strings.TrimPrefix(name, ref.name), true

      
        
        173
        +	}

      
        
        174
        +	if name != ref.name {

      
        
        175
        +		return "", false

      
        
        176
        +	}

      
        
        177
        +	return newName, true

      
        
        178
        +}

      
        
        179
        +

      
        
        180
        +func accountMatches(name, old string) bool {

      
        
        181
        +	return name == old || strings.HasPrefix(name, old+":")

      
        
        182
        +}

      
        
        183
        +

      
        
        184
        +func tagRefInComment(content string, c *ast.Comment, cursor int) *symbolRef {

      
        
        185
        +	if c == nil {

      
        
        186
        +		return nil

      
        
        187
        +	}

      
        
        188
        +	for i := range c.Tags {

      
        
        189
        +		t := &c.Tags[i]

      
        
        190
        +		if span := tagKeySpan(content, t); spanContains(content, span, cursor) {

      
        
        191
        +			return &symbolRef{symbolTag, t.Key, span}

      
        
        192
        +		}

      
        
        193
        +	}

      
        
        194
        +	return nil

      
        
        195
        +}

      
        
        196
        +

      
        
        197
        +func commodityRef(content string, am *ast.Amount, cursor int) *symbolRef {

      
        
        198
        +	if am == nil || am.Commodity == "" || !spanContains(content, am.CommoditySpan, cursor) {

      
        
        199
        +		return nil

      
        
        200
        +	}

      
        
        201
        +	return &symbolRef{symbolCommodity, am.Commodity, am.CommoditySpan}

      
        
        202
        +}

      
        
        203
        +

      
        
        204
        +func symbolInPostings(content string, postings []*ast.Posting, cursor int) *symbolRef {

      
        
        205
        +	for _, p := range postings {

      
        
        206
        +		if spanContains(content, p.Account.Span, cursor) {

      
        
        207
        +			return &symbolRef{symbolAccount, p.Account.String(), p.Account.Span}

      
        
        208
        +		}

      
        
        209
        +		if ref := commodityRef(content, p.Amount, cursor); ref != nil {

      
        
        210
        +			return ref

      
        
        211
        +		}

      
        
        212
        +		if p.Cost != nil {

      
        
        213
        +			if ref := commodityRef(content, &p.Cost.Amount, cursor); ref != nil {

      
        
        214
        +				return ref

      
        
        215
        +			}

      
        
        216
        +		}

      
        
        217
        +		if p.Balance != nil {

      
        
        218
        +			if ref := commodityRef(content, &p.Balance.Amount, cursor); ref != nil {

      
        
        219
        +				return ref

      
        
        220
        +			}

      
        
        221
        +		}

      
        
        222
        +		if ref := tagRefInComment(content, p.Comment, cursor); ref != nil {

      
        
        223
        +			return ref

      
        
        224
        +		}

      
        
        225
        +		for i := range p.Comments {

      
        
        226
        +			if ref := tagRefInComment(content, &p.Comments[i], cursor); ref != nil {

      
        
        227
        +				return ref

      
        
        228
        +			}

      
        
        229
        +		}

      
        
        230
        +	}

      
        
        231
        +	return nil

      
        
        232
        +}

      
        
        233
        +

      
        
        234
        +// renameChanges collects the workspace edits renaming ref to newName

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

      
        
        236
        +	changes := make(map[uri.URI][]protocol.TextEdit)

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

      
        
        238
        +		content := string(pf.Src)

      
        
        239
        +		var edits []protocol.TextEdit

      
        
        240
        +		add := func(span token.Span, text string) {

      
        
        241
        +			edits = append(edits, protocol.TextEdit{

      
        
        242
        +				Range:   spanToProtocolRange(content, span),

      
        
        243
        +				NewText: text,

      
        
        244
        +			})

      
        
        245
        +		}

      
        
        246
        +		for _, e := range pf.Ast.Entries {

      
        
        247
        +			renameEntry(add, ref, newName, content, e)

      
        
        248
        +		}

      
        
        249
        +		if len(edits) > 0 {

      
        
        250
        +			changes[uri.File(pf.Path)] = edits

      
        
        251
        +		}

      
        
        252
        +	}

      
        
        253
        +	sortAndDedup(changes)

      
        
        254
        +	return changes

      
        
        255
        +}

      
        
        256
        +

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

      
        
        258
        +	switch e := e.(type) {

      
        
        259
        +	case *ast.Transaction:

      
        
        260
        +		renamePayee(add, ref, newName, e.Payee)

      
        
        261
        +		renameCommentTags(add, ref, newName, content, e.Comment)

      
        
        262
        +		for _, c := range e.HeaderComments {

      
        
        263
        +			renameCommentTags(add, ref, newName, content, c)

      
        
        264
        +		}

      
        
        265
        +		renamePostings(add, ref, newName, content, e.Postings)

      
        
        266
        +	case *ast.PeriodicTransaction:

      
        
        267
        +		renameCommentTags(add, ref, newName, content, e.Comment)

      
        
        268
        +		for _, c := range e.HeaderComments {

      
        
        269
        +			renameCommentTags(add, ref, newName, content, c)

      
        
        270
        +		}

      
        
        271
        +		renamePostings(add, ref, newName, content, e.Postings)

      
        
        272
        +	case *ast.AutomatedTransaction:

      
        
        273
        +		renameCommentTags(add, ref, newName, content, e.Comment)

      
        
        274
        +		for _, c := range e.HeaderComments {

      
        
        275
        +			renameCommentTags(add, ref, newName, content, c)

      
        
        276
        +		}

      
        
        277
        +		renamePostings(add, ref, newName, content, e.Postings)

      
        
        278
        +	case *ast.Comment:

      
        
        279
        +		renameCommentTags(add, ref, newName, content, e)

      
        
        280
        +	case *ast.AccountDirective:

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

      
        
        282
        +			add(e.Account.Span, text)

      
        
        283
        +		}

      
        
        284
        +	case *ast.CommodityDirective:

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

      
        
        286
        +			add(e.CommoditySpan, text)

      
        
        287
        +		}

      
        
        288
        +	case *ast.PayeeDirective:

      
        
        289
        +		renamePayee(add, ref, newName, e.Name)

      
        
        290
        +	case *ast.TagDirective:

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

      
        
        292
        +			if span, ok := tagDirectiveSpan(content, e); ok {

      
        
        293
        +				add(span, text)

      
        
        294
        +			}

      
        
        295
        +		}

      
        
        296
        +	}

      
        
        297
        +}

      
        
        298
        +

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

      
        
        300
        +	for _, p := range postings {

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

      
        
        302
        +			add(p.Account.Span, text)

      
        
        303
        +		}

      
        
        304
        +		renameCommodity(add, ref, newName, p.Amount)

      
        
        305
        +		if p.Cost != nil {

      
        
        306
        +			renameCommodity(add, ref, newName, &p.Cost.Amount)

      
        
        307
        +		}

      
        
        308
        +		if p.Balance != nil {

      
        
        309
        +			renameCommodity(add, ref, newName, &p.Balance.Amount)

      
        
        310
        +		}

      
        
        311
        +		renameCommentTags(add, ref, newName, content, p.Comment)

      
        
        312
        +		for i := range p.Comments {

      
        
        313
        +			renameCommentTags(add, ref, newName, content, &p.Comments[i])

      
        
        314
        +		}

      
        
        315
        +	}

      
        
        316
        +}

      
        
        317
        +

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

      
        
        319
        +	if am == nil {

      
        
        320
        +		return

      
        
        321
        +	}

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

      
        
        323
        +		add(am.CommoditySpan, text)

      
        
        324
        +	}

      
        
        325
        +}

      
        
        326
        +

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

      
        
        328
        +	if p == nil {

      
        
        329
        +		return

      
        
        330
        +	}

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

      
        
        332
        +		add(p.Span, text)

      
        
        333
        +	}

      
        
        334
        +}

      
        
        335
        +

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

      
        
        337
        +	if c == nil {

      
        
        338
        +		return

      
        
        339
        +	}

      
        
        340
        +	for i := range c.Tags {

      
        
        341
        +		t := &c.Tags[i]

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

      
        
        343
        +			add(tagKeySpan(content, t), text)

      
        
        344
        +		}

      
        
        345
        +	}

      
        
        346
        +}

      
        
        347
        +

      
        
        348
        +func tagDirectiveSpan(content string, d *ast.TagDirective) (token.Span, bool) {

      
        
        349
        +	end := d.Span.End.Offset

      
        
        350
        +	if d.Comment != nil {

      
        
        351
        +		end = d.Comment.Span.Start.Offset

      
        
        352
        +	}

      
        
        353
        +	return betweenSpan(content, d.Span.Start.File, d.Span.Start.Offset+len("tag"), end)

      
        
        354
        +}

      
        
        355
        +

      
        
        356
        +func tagKeySpan(content string, t *ast.Tag) token.Span {

      
        
        357
        +	end := t.Span.End.Offset

      
        
        358
        +	for off := t.Span.Start.Offset; off < end; off++ {

      
        
        359
        +		if content[off] == ':' || content[off] == ',' {

      
        
        360
        +			end = off

      
        
        361
        +			break

      
        
        362
        +		}

      
        
        363
        +	}

      
        
        364
        +	for end > t.Span.Start.Offset && (content[end-1] == ' ' || content[end-1] == '\t') {

      
        
        365
        +		end--

      
        
        366
        +	}

      
        
        367
        +	return token.Span{Start: t.Span.Start, End: offsetPos(t.Span.Start.File, end)}

      
        
        368
        +}

      
        
        369
        +

      
        
        370
        +func sortAndDedup(changes map[uri.URI][]protocol.TextEdit) {

      
        
        371
        +	for u, edits := range changes {

      
        
        372
        +		sort.Slice(edits, func(i, j int) bool {

      
        
        373
        +			ri, rj := edits[i].Range, edits[j].Range

      
        
        374
        +			if ri.Start.Line != rj.Start.Line {

      
        
        375
        +				return ri.Start.Line < rj.Start.Line

      
        
        376
        +			}

      
        
        377
        +			return ri.Start.Character < rj.Start.Character

      
        
        378
        +		})

      
        
        379
        +		dedup := edits[:0]

      
        
        380
        +		for _, e := range edits {

      
        
        381
        +			if len(dedup) == 0 || dedup[len(dedup)-1] != e {

      
        
        382
        +				dedup = append(dedup, e)

      
        
        383
        +			}

      
        
        384
        +		}

      
        
        385
        +		changes[u] = dedup

      
        
        386
        +	}

      
        
        387
        +}

      
        
        388
        +

      
        
        389
        +// Validation

      
        
        390
        +

      
        
        391
        +func validateAccountName(name string) error   { return validateRenameName(name, "account", ";") }

      
        
        392
        +func validateCommodityName(name string) error { return validateRenameName(name, "commodity", ";") }

      
        
        393
        +func validatePayeeName(name string) error     { return validateRenameName(name, "payee", ";|") }

      
        
        394
        +func validateTagName(name string) error       { return validateRenameName(name, "tag", ":,; \t") }

      
        
        395
        +func validateRenameName(name, what, forbidden string) error {

      
        
        396
        +	if name == "" {

      
        
        397
        +		return fmt.Errorf("%s name must not be empty", what)

      
        
        398
        +	}

      
        
        399
        +	if name != strings.TrimSpace(name) {

      
        
        400
        +		return fmt.Errorf("%s name must not have leading or trailing whitespace", what)

      
        
        401
        +	}

      
        
        402
        +	for _, r := range name {

      
        
        403
        +		if strings.ContainsRune(forbidden, r) || r == '\n' || r == '\r' {

      
        
        404
        +			return fmt.Errorf("%s name contains illegal character %q", what, r)

      
        
        405
        +		}

      
        
        406
        +	}

      
        
        407
        +	return nil

      
        
        408
        +}

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +	"os"

      
        
        6
        +	"path/filepath"

      
        
        7
        +	"sort"

      
        
        8
        +	"strings"

      
        
        9
        +	"testing"

      
        
        10
        +

      
        
        11
        +	"go.lsp.dev/protocol"

      
        
        12
        +	"go.lsp.dev/uri"

      
        
        13
        +

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

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

      
        
        16
        +)

      
        
        17
        +

      
        
        18
        +func TestValidateRenameName(t *testing.T) {

      
        
        19
        +	valid := map[string][]string{

      
        
        20
        +		"account":   {"expenses", "expenses:food", "a:b:c"},

      
        
        21
        +		"commodity": {"$", "€", "USD", "US$"},

      
        
        22
        +		"payee":     {"Grocery Store", "acme", "продукти"},

      
        
        23
        +		"tag":       {"client", "a-b_c", "x123"},

      
        
        24
        +	}

      
        
        25
        +

      
        
        26
        +	invalid := map[string][]string{

      
        
        27
        +		"account":   {"", " expenses", "expenses ", "expenses;x", "expenses\nx", "expenses\r"},

      
        
        28
        +		"commodity": {"", " $", "$ ", "$;x", "$\n"},

      
        
        29
        +		"payee":     {"", " payee", "payee|note", "payee;x"},

      
        
        30
        +		"tag":       {"", "a:b", "a,b", "a b", "a;b", "tag\tx"},

      
        
        31
        +	}

      
        
        32
        +

      
        
        33
        +	validators := map[string]func(string) error{

      
        
        34
        +		"account":   validateAccountName,

      
        
        35
        +		"commodity": validateCommodityName,

      
        
        36
        +		"payee":     validatePayeeName,

      
        
        37
        +		"tag":       validateTagName,

      
        
        38
        +	}

      
        
        39
        +

      
        
        40
        +	for kind, v := range validators {

      
        
        41
        +		for _, name := range valid[kind] {

      
        
        42
        +			if err := v(name); err != nil {

      
        
        43
        +				t.Errorf("%s %q: unexpected error %v", kind, name, err)

      
        
        44
        +			}

      
        
        45
        +		}

      
        
        46
        +		for _, name := range invalid[kind] {

      
        
        47
        +			if err := v(name); err == nil {

      
        
        48
        +				t.Errorf("%s %q: expected error", kind, name)

      
        
        49
        +			}

      
        
        50
        +		}

      
        
        51
        +	}

      
        
        52
        +}

      
        
        53
        +

      
        
        54
        +func TestAccountMatches(t *testing.T) {

      
        
        55
        +	tests := map[string]bool{

      
        
        56
        +		"expenses":        true,

      
        
        57
        +		"expenses:food":   true,

      
        
        58
        +		"expenses:food:l": true,

      
        
        59
        +		"expenses2":       false,

      
        
        60
        +		"ex":              false,

      
        
        61
        +		"other":           false,

      
        
        62
        +	}

      
        
        63
        +	for name, want := range tests {

      
        
        64
        +		if got := accountMatches(name, "expenses"); got != want {

      
        
        65
        +			t.Errorf("accountMatches(%q, expenses) = %v, want %v", name, got, want)

      
        
        66
        +		}

      
        
        67
        +	}

      
        
        68
        +}

      
        
        69
        +

      
        
        70
        +// Golden

      
        
        71
        +

      
        
        72
        +func TestRenameTxtar(t *testing.T) {

      
        
        73
        +	for _, tt := range []string{"rename-account", "rename-commodity", "rename-payee", "rename-tag"} {

      
        
        74
        +		ar := golden.Read(t, tt)

      
        
        75
        +		t.Run(tt, func(t *testing.T) {

      
        
        76
        +			h := newTxtarHarness(t, ar)

      
        
        77
        +

      
        
        78
        +			newNames := strings.Split(strings.TrimRight(string(ar.Get("rename")), "\n"), "\n")

      
        
        79
        +			if len(newNames) != len(h.cursors) {

      
        
        80
        +				t.Fatalf("%d cursors, %d rename names", len(h.cursors), len(newNames))

      
        
        81
        +			}

      
        
        82
        +

      
        
        83
        +			var b strings.Builder

      
        
        84
        +			for i := range h.cursors {

      
        
        85
        +				tdp := h.textDocumentPosition(i)

      
        
        86
        +				prep, err := h.srv.PrepareRename(t.Context(), &protocol.PrepareRenameParams{TextDocumentPositionParams: tdp})

      
        
        87
        +				if err != nil {

      
        
        88
        +					t.Fatal(err)

      
        
        89
        +				}

      
        
        90
        +				edit, err := h.srv.Rename(t.Context(), &protocol.RenameParams{

      
        
        91
        +					TextDocumentPositionParams: tdp,

      
        
        92
        +					NewName:                    newNames[i],

      
        
        93
        +				})

      
        
        94
        +				if err != nil {

      
        
        95
        +					t.Fatal(err)

      
        
        96
        +				}

      
        
        97
        +

      
        
        98
        +				fmt.Fprintf(&b, "%d:%d\n", tdp.Position.Line, tdp.Position.Character)

      
        
        99
        +				if p, ok := prep.(*protocol.PrepareRenamePlaceholder); ok {

      
        
        100
        +					r := p.Range

      
        
        101
        +					fmt.Fprintf(&b, "  prep in.journal %d:%d-%d:%d %q\n",

      
        
        102
        +						r.Start.Line, r.Start.Character, r.End.Line, r.End.Character, p.Placeholder)

      
        
        103
        +				} else {

      
        
        104
        +					b.WriteString("  prep <none>\n")

      
        
        105
        +				}

      
        
        106
        +				if edit == nil {

      
        
        107
        +					b.WriteString("  ren <none>\n")

      
        
        108
        +					continue

      
        
        109
        +				}

      
        
        110
        +				uris := make([]string, 0, len(edit.Changes))

      
        
        111
        +				for u := range edit.Changes {

      
        
        112
        +					uris = append(uris, string(u))

      
        
        113
        +				}

      
        
        114
        +				sort.Strings(uris)

      
        
        115
        +				for _, us := range uris {

      
        
        116
        +					for _, e := range edit.Changes[uri.URI(us)] {

      
        
        117
        +						r := e.Range

      
        
        118
        +						fmt.Fprintf(&b, "  ren %s %d:%d-%d:%d %q\n", filepath.Base(uri.URI(us).Path()),

      
        
        119
        +							r.Start.Line, r.Start.Character, r.End.Line, r.End.Character, e.NewText)

      
        
        120
        +					}

      
        
        121
        +				}

      
        
        122
        +			}

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

      
        
        124
        +		})

      
        
        125
        +	}

      
        
        126
        +}

      
        
        127
        +

      
        
        128
        +// txtarHarness runs per-cursor lsp requests againt golden tests

      
        
        129
        +type txtarHarness struct {

      
        
        130
        +	srv     *server

      
        
        131
        +	uri     uri.URI

      
        
        132
        +	content string

      
        
        133
        +	cursors []int

      
        
        134
        +}

      
        
        135
        +

      
        
        136
        +func newTxtarHarness(t *testing.T, ar *golden.Archive) *txtarHarness {

      
        
        137
        +	t.Helper()

      
        
        138
        +	dir := t.TempDir()

      
        
        139
        +	for _, f := range ar.Files {

      
        
        140
        +		if f.Name == "expect" || f.Name == "rename" {

      
        
        141
        +			continue

      
        
        142
        +		}

      
        
        143
        +		if err := os.WriteFile(filepath.Join(dir, f.Name), f.Data, 0o644); err != nil {

      
        
        144
        +			t.Fatal(err)

      
        
        145
        +		}

      
        
        146
        +	}

      
        
        147
        +

      
        
        148
        +	content, cursors := stripCursors(string(ar.Get("in.journal")))

      
        
        149
        +	if len(cursors) == 0 {

      
        
        150
        +		t.Fatal("no '^' markers in in.journal")

      
        
        151
        +	}

      
        
        152
        +	u := uri.File(filepath.Join(dir, "in.journal"))

      
        
        153
        +

      
        
        154
        +	srv := NewServer("test")

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

      
        
        156
        +

      
        
        157
        +	return &txtarHarness{srv: srv.server, uri: u, content: content, cursors: cursors}

      
        
        158
        +}

      
        
        159
        +

      
        
        160
        +func (h *txtarHarness) textDocumentPosition(i int) protocol.TextDocumentPositionParams {

      
        
        161
        +	return protocol.TextDocumentPositionParams{

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

      
        
        163
        +		Position:     lsputil.Position(h.content, h.cursors[i]),

      
        
        164
        +	}

      
        
        165
        +}

      
        
        166
        +

      
        
        167
        +func stripCursors(content string) (string, []int) {

      
        
        168
        +	var cursors []int

      
        
        169
        +	for {

      
        
        170
        +		i := strings.Index(content, "^")

      
        
        171
        +		if i < 0 {

      
        
        172
        +			return content, cursors

      
        
        173
        +		}

      
        
        174
        +		cursors = append(cursors, i)

      
        
        175
        +		content = content[:i] + content[i+1:]

      
        
        176
        +	}

      
        
        177
        +}