From ded9cabdf4184bec33bdbd18191c58deac30a331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Wed, 23 Sep 2026 20:19:21 +0200 Subject: [PATCH] fix(lsp): apply each rename edit to its own document Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 8 ++++++++ lsp/handlers.go | 12 ++++++------ lsp/server_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56a09be..2339f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - +### Fixed + +- **Rename edits land in their own documents.** A rename collected the + ranges of every reference across the open documents but applied them all + to the document that started it, so renaming a symbol used in a second + file moved that file's text into the first. Each edit now applies to the + document it was collected in. + ## [0.35.0] - 2026-09-22 ### Added diff --git a/lsp/handlers.go b/lsp/handlers.go index 5e8ee0e..294e2ef 100644 --- a/lsp/handlers.go +++ b/lsp/handlers.go @@ -229,7 +229,9 @@ func (s *Server) references(p referenceParams) []Location { return out } -// rename renames a label across the document. +// rename renames a label across every document the references reach. Each +// edit is grouped under the URI it belongs to: a range collected in another +// file must never be applied to the document that started the rename. func (s *Server) rename(p renameParams) *WorkspaceEdit { text := s.docs[p.TextDocument.URI] word, _ := wordAt(text, p.Position) @@ -244,13 +246,11 @@ func (s *Server) rename(p renameParams) *WorkspaceEdit { if len(refs) == 0 { return nil } - edits := make([]TextEdit, 0, len(refs)) + edits := make(map[string][]TextEdit, len(refs)) for _, loc := range refs { - edits = append(edits, TextEdit{Range: loc.Range, NewText: p.NewName}) - } - return &WorkspaceEdit{ - Changes: map[string][]TextEdit{p.TextDocument.URI: edits}, + edits[loc.URI] = append(edits[loc.URI], TextEdit{Range: loc.Range, NewText: p.NewName}) } + return &WorkspaceEdit{Changes: edits} } // documentFormatting returns a single edit that replaces the whole document diff --git a/lsp/server_test.go b/lsp/server_test.go index eebcad1..d9be465 100644 --- a/lsp/server_test.go +++ b/lsp/server_test.go @@ -1074,3 +1074,49 @@ func TestCrossFileDefinitionAndReferences(t *testing.T) { t.Errorf("references by uri = %v, want one in each file", byURI) } } + +// TestRenameGroupsEditsByDocument pins the grouping contract: every edit is +// applied to the document it was collected in, so a cross-file rename never +// corrupts the origin buffer. +func TestRenameGroupsEditsByDocument(t *testing.T) { + docA := "TEXT \u00b7caller(SB), NOSPLIT, $0\n" + + "\tCALL \u00b7helper(SB)\n" + + "\tRET\n" + docB := "TEXT \u00b7helper(SB), NOSPLIT, $0\n" + + "\tRET\n" + uriA := "file:///rename_a_amd64.s" + uriB := "file:///rename_b_amd64.s" + in := frame(1, "initialize", map[string]any{}) + + frame(nil, "initialized", map[string]any{}) + + frame(nil, "textDocument/didOpen", map[string]any{ + "textDocument": map[string]any{"uri": uriA, "languageId": "gasm", "version": 1, "text": docA}, + }) + + frame(nil, "textDocument/didOpen", map[string]any{ + "textDocument": map[string]any{"uri": uriB, "languageId": "gasm", "version": 1, "text": docB}, + }) + + frame(2, "textDocument/rename", map[string]any{ + "textDocument": map[string]any{"uri": uriA}, + "position": map[string]any{"line": 1, "character": 8}, // on helper in CALL ·helper(SB) + "newName": "renamed", + }) + frame(nil, "exit", nil) + msgs := run(t, in) + resp := findByID(msgs, 2) + if resp == nil { + t.Fatal("no rename response") + } + var edit WorkspaceEdit + if err := json.Unmarshal(mustResult(t, resp), &edit); err != nil { + t.Fatal(err) + } + if len(edit.Changes[uriA]) != 1 || len(edit.Changes[uriB]) != 1 { + t.Fatalf("edits per uri = %d and %d, want one each", len(edit.Changes[uriA]), len(edit.Changes[uriB])) + } + gotA := applyEdits(docA, edit.Changes[uriA]) + gotB := applyEdits(docB, edit.Changes[uriB]) + if !strings.Contains(gotA, "CALL renamed(SB)") || strings.Contains(gotA, "renamed(SB), NOSPLIT") { + t.Errorf("caller document rename wrong: %q", gotA) + } + if !strings.Contains(gotB, "TEXT renamed(SB)") { + t.Errorf("helper document rename wrong: %q", gotB) + } +}