Skip to content

Commit 74a3485

Browse files
committed
Implement rename symbol functionality
1 parent 1a62653 commit 74a3485

4 files changed

Lines changed: 217 additions & 0 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ https://user-images.githubusercontent.com/348107/120141150-c6bb9180-c1fd-11eb-8a
1616
- 🔍 Go to Definition, Declaration, and Type Definition
1717
- 🔦 Document Highlight
1818
- 🎨 Document Formatting and Range Formatting
19+
- ✏️ Rename Symbol
1920

2021
## Usage
2122

@@ -109,6 +110,21 @@ formattingOptions.of({
109110

110111
By default, `tabSize` is read from the editor state.
111112

113+
### Rename Symbol
114+
115+
`renameSymbol` is a CodeMirror command that opens a rename prompt at the top of the editor:
116+
117+
```js
118+
import { keymap } from '@codemirror/view';
119+
import { renameSymbol } from 'codemirror-languageserver';
120+
121+
keymap.of([
122+
{ key: 'F2', run: renameSymbol },
123+
])
124+
```
125+
126+
Style the rename panel with CSS using the `cm-lsp-rename-panel` and `cm-lsp-rename-input` classes.
127+
112128
### Using with Initialization Options
113129

114130
The plugin includes built-in TypeScript definitions for popular language servers:

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export {
1515
formatSelection,
1616
formattingOptions,
1717
} from './formatting';
18+
export { renameSymbol } from './rename';
1819
export {
1920
PyrightInitializationOptions,
2021
RustAnalyzerInitializationOptions,
@@ -41,6 +42,7 @@ import { hoverTooltip } from './hover';
4142
import { autocompletion } from './completion';
4243
import { documentHighlight } from './highlight';
4344
import { mouseHandler } from './mouse';
45+
import { renameExtension } from './rename';
4446

4547
interface LanguageServerOptions<InitializationOptions = unknown>
4648
extends LanguageServerClientOptions<InitializationOptions> {
@@ -85,6 +87,7 @@ export function languageServerWithTransport<InitializationOptions = unknown>(
8587
hoverTooltip(),
8688
autocompletion(),
8789
documentHighlight(),
90+
renameExtension(),
8891
keymap.of([...jumpToDefinitionKeymap]),
8992
mouseHandler(),
9093
];

src/plugin.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ interface LSPRequestMap {
7373
LSP.DocumentRangeFormattingParams,
7474
LSP.TextEdit[] | null,
7575
];
76+
'textDocument/prepareRename': [
77+
LSP.PrepareRenameParams,
78+
LSP.Range | { range: LSP.Range; placeholder: string } | null,
79+
];
80+
'textDocument/rename': [LSP.RenameParams, LSP.WorkspaceEdit | null];
7681
'completionItem/resolve': [LSP.CompletionItem, LSP.CompletionItem];
7782
}
7883

@@ -212,6 +217,10 @@ export class LanguageServerClient<InitializationOptions = unknown> {
212217
rangeFormatting: {
213218
dynamicRegistration: true,
214219
},
220+
rename: {
221+
dynamicRegistration: true,
222+
prepareSupport: true,
223+
},
215224
},
216225
workspace: {
217226
didChangeConfiguration: {
@@ -302,6 +311,18 @@ export class LanguageServerClient<InitializationOptions = unknown> {
302311
);
303312
}
304313

314+
public async textDocumentPrepareRename(params: LSP.PrepareRenameParams) {
315+
return await this.request(
316+
'textDocument/prepareRename',
317+
params,
318+
timeout,
319+
);
320+
}
321+
322+
public async textDocumentRename(params: LSP.RenameParams) {
323+
return await this.request('textDocument/rename', params, timeout);
324+
}
325+
305326
public async completionItemResolve(
306327
params: LSP.CompletionItem,
307328
): Promise<LSP.CompletionItem> {

src/rename.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { StateEffect, StateField } from '@codemirror/state';
2+
import { EditorView, showPanel, Panel } from '@codemirror/view';
3+
import type * as LSP from 'vscode-languageserver-protocol';
4+
5+
import { languageServerPlugin } from './plugin';
6+
import { offsetToPos, posToOffset } from './pos';
7+
8+
const openRenamePanel = StateEffect.define<string>();
9+
const closeRenamePanel = StateEffect.define<null>();
10+
11+
const renamePanelState = StateField.define<string | null>({
12+
create() {
13+
return null;
14+
},
15+
update(value, tr) {
16+
for (const effect of tr.effects) {
17+
if (effect.is(openRenamePanel)) return effect.value;
18+
if (effect.is(closeRenamePanel)) return null;
19+
}
20+
return value;
21+
},
22+
provide: (field) =>
23+
showPanel.from(field, (value) =>
24+
value != null ? (view) => createRenamePanel(view, value) : null,
25+
),
26+
});
27+
28+
function applyWorkspaceEdit(view: EditorView, edit: LSP.WorkspaceEdit) {
29+
const plugin = view.plugin(languageServerPlugin);
30+
if (!plugin) return;
31+
32+
const changes: { from: number; to: number; insert: string }[] = [];
33+
34+
if (edit.changes) {
35+
const edits = edit.changes[plugin.documentUri];
36+
if (edits) {
37+
for (const e of edits) {
38+
changes.push({
39+
from: posToOffset(view.state.doc, e.range.start),
40+
to: posToOffset(view.state.doc, e.range.end),
41+
insert: e.newText,
42+
});
43+
}
44+
}
45+
}
46+
47+
if (edit.documentChanges) {
48+
for (const change of edit.documentChanges) {
49+
if ('textDocument' in change) {
50+
if (change.textDocument.uri !== plugin.documentUri) continue;
51+
for (const e of change.edits) {
52+
if ('range' in e) {
53+
changes.push({
54+
from: posToOffset(view.state.doc, e.range.start),
55+
to: posToOffset(view.state.doc, e.range.end),
56+
insert: e.newText,
57+
});
58+
}
59+
}
60+
}
61+
}
62+
}
63+
64+
if (changes.length > 0) {
65+
view.dispatch(view.state.update({ changes }));
66+
}
67+
}
68+
69+
function createRenamePanel(view: EditorView, placeholder: string): Panel {
70+
const dom = document.createElement('div');
71+
dom.className = 'cm-lsp-rename-panel';
72+
73+
const input = document.createElement('input');
74+
input.className = 'cm-lsp-rename-input';
75+
input.value = placeholder;
76+
input.setAttribute('aria-label', 'New name');
77+
78+
const submit = () => {
79+
const newName = input.value.trim();
80+
if (!newName || newName === placeholder) {
81+
view.dispatch({ effects: closeRenamePanel.of(null) });
82+
view.focus();
83+
return;
84+
}
85+
performRename(view, newName);
86+
};
87+
88+
input.addEventListener('keydown', (e) => {
89+
if (e.key === 'Enter') {
90+
e.preventDefault();
91+
submit();
92+
} else if (e.key === 'Escape') {
93+
e.preventDefault();
94+
view.dispatch({ effects: closeRenamePanel.of(null) });
95+
view.focus();
96+
}
97+
});
98+
99+
dom.appendChild(input);
100+
101+
return {
102+
dom,
103+
top: true,
104+
mount() {
105+
input.select();
106+
},
107+
};
108+
}
109+
110+
async function performRename(view: EditorView, newName: string) {
111+
const plugin = view.plugin(languageServerPlugin);
112+
if (!plugin) return;
113+
114+
const pos = view.state.selection.main.head;
115+
116+
view.dispatch({ effects: closeRenamePanel.of(null) });
117+
view.focus();
118+
119+
const result = await plugin.client.textDocumentRename({
120+
textDocument: { uri: plugin.documentUri },
121+
position: offsetToPos(view.state.doc, pos),
122+
newName,
123+
});
124+
125+
if (result) {
126+
applyWorkspaceEdit(view, result);
127+
}
128+
}
129+
130+
export function renameSymbol(view: EditorView): boolean {
131+
const plugin = view.plugin(languageServerPlugin);
132+
if (!plugin) return false;
133+
134+
if (
135+
!plugin.client.ready ||
136+
!plugin.client.capabilities?.renameProvider
137+
) {
138+
return false;
139+
}
140+
141+
const pos = view.state.selection.main.head;
142+
const capabilities = plugin.client.capabilities;
143+
const renameProvider = capabilities.renameProvider;
144+
const supportsPrepare =
145+
typeof renameProvider === 'object' && renameProvider.prepareProvider;
146+
147+
if (supportsPrepare) {
148+
plugin.client
149+
.textDocumentPrepareRename({
150+
textDocument: { uri: plugin.documentUri },
151+
position: offsetToPos(view.state.doc, pos),
152+
})
153+
.then((result) => {
154+
if (!result) return;
155+
const placeholder =
156+
'placeholder' in result
157+
? result.placeholder
158+
: view.state.doc.sliceString(
159+
posToOffset(view.state.doc, result.start),
160+
posToOffset(view.state.doc, result.end),
161+
);
162+
view.dispatch({
163+
effects: openRenamePanel.of(placeholder),
164+
});
165+
});
166+
} else {
167+
const word = view.state.wordAt(pos);
168+
const placeholder = word
169+
? view.state.doc.sliceString(word.from, word.to)
170+
: '';
171+
view.dispatch({ effects: openRenamePanel.of(placeholder) });
172+
}
173+
174+
return true;
175+
}
176+
177+
export const renameExtension = () => renamePanelState;

0 commit comments

Comments
 (0)