Skip to content
This repository was archived by the owner on Oct 16, 2020. It is now read-only.

Commit 6436c74

Browse files
authored
Implement textDocument/rename (#251)
1 parent 61edbfa commit 6436c74

File tree

4 files changed

+215
-4
lines changed

4 files changed

+215
-4
lines changed

src/test/typescript-service-helpers.ts

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import * as chai from 'chai';
22
import * as sinon from 'sinon';
33
import * as ts from 'typescript';
44
import { CompletionItemKind, CompletionList, TextDocumentIdentifier, TextDocumentItem } from 'vscode-languageserver';
5-
import { Hover, Location, SignatureHelp, SymbolInformation, SymbolKind } from 'vscode-languageserver-types';
5+
import { Hover, Location, SignatureHelp, SymbolInformation, SymbolKind, WorkspaceEdit } from 'vscode-languageserver-types';
66
import { LanguageClient, RemoteLanguageClient } from '../lang-handler';
77
import { TextDocumentContentParams, WorkspaceFilesParams } from '../request-type';
88
import { SymbolLocationInformation } from '../request-type';
99
import { TypeScriptService, TypeScriptServiceFactory } from '../typescript-service';
1010
import chaiAsPromised = require('chai-as-promised');
1111
import { apply } from 'json-patch';
12-
import { ITestCallbackContext } from 'mocha';
12+
import { ISuiteCallbackContext, ITestCallbackContext } from 'mocha';
1313
chai.use(chaiAsPromised);
1414
const assert = chai.assert;
1515

@@ -2172,6 +2172,144 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor
21722172
});
21732173
} as any);
21742174

2175+
describe('textDocumentRename()', function (this: TestContext & ISuiteCallbackContext) {
2176+
beforeEach(initializeTypeScriptService(createService, rootUri, new Map([
2177+
[rootUri + 'package.json', JSON.stringify({ name: 'mypkg' })],
2178+
[rootUri + 'a.ts', [
2179+
'class A {',
2180+
' /** foo doc*/',
2181+
' foo() {}',
2182+
' /** bar doc*/',
2183+
' bar(): number { return 1; }',
2184+
' /** baz doc*/',
2185+
' baz(): string { return ""; }',
2186+
' /** qux doc*/',
2187+
' qux: number;',
2188+
'}',
2189+
'const a = new A();',
2190+
'a.'
2191+
].join('\n')],
2192+
[rootUri + 'uses-import.ts', [
2193+
'import {d} from "./import"',
2194+
'const x = d();'
2195+
].join('\n')],
2196+
[rootUri + 'import.ts', 'export function d(): number { return 55; }']
2197+
])) as any);
2198+
2199+
afterEach(shutdownService as any);
2200+
2201+
it('should error on an invalid symbol', async function (this: TestContext & ITestCallbackContext) {
2202+
await assert.isRejected(
2203+
this.service.textDocumentRename({
2204+
textDocument: {
2205+
uri: rootUri + 'a.ts'
2206+
},
2207+
position: {
2208+
line: 0,
2209+
character: 1
2210+
},
2211+
newName: 'asdf'
2212+
}).toArray().map(patches => apply(null, patches)).toPromise(),
2213+
'This symbol cannot be renamed'
2214+
);
2215+
});
2216+
it('should return a correct WorkspaceEdit to rename a class', async function (this: TestContext & ITestCallbackContext) {
2217+
const result: WorkspaceEdit = await this.service.textDocumentRename({
2218+
textDocument: {
2219+
uri: rootUri + 'a.ts'
2220+
},
2221+
position: {
2222+
line: 0,
2223+
character: 6
2224+
},
2225+
newName: 'B'
2226+
}).toArray().map(patches => apply(null, patches)).toPromise();
2227+
assert.deepEqual(result, {
2228+
changes: {
2229+
[rootUri + 'a.ts']: [{
2230+
newText: 'B',
2231+
range: {
2232+
end: {
2233+
character: 7,
2234+
line: 0
2235+
},
2236+
start: {
2237+
character: 6,
2238+
line: 0
2239+
}
2240+
}
2241+
}, {
2242+
newText: 'B',
2243+
range: {
2244+
end: {
2245+
character: 15,
2246+
line: 10
2247+
},
2248+
start: {
2249+
character: 14,
2250+
line: 10
2251+
}
2252+
}
2253+
}]
2254+
}
2255+
});
2256+
});
2257+
it('should return a correct WorkspaceEdit to rename an imported function', async function (this: TestContext & ITestCallbackContext) {
2258+
const result: WorkspaceEdit = await this.service.textDocumentRename({
2259+
textDocument: {
2260+
uri: rootUri + 'import.ts'
2261+
},
2262+
position: {
2263+
line: 0,
2264+
character: 16
2265+
},
2266+
newName: 'f'
2267+
}).toArray().map(patches => apply(null, patches)).toPromise();
2268+
assert.deepEqual(result, {
2269+
changes: {
2270+
[rootUri + 'import.ts']: [{
2271+
newText: 'f',
2272+
range: {
2273+
end: {
2274+
character: 17,
2275+
line: 0
2276+
},
2277+
start: {
2278+
character: 16,
2279+
line: 0
2280+
}
2281+
}
2282+
}],
2283+
[rootUri + 'uses-import.ts']: [{
2284+
newText: 'f',
2285+
range: {
2286+
end: {
2287+
character: 9,
2288+
line: 0
2289+
},
2290+
start: {
2291+
character: 8,
2292+
line: 0
2293+
}
2294+
}
2295+
}, {
2296+
newText: 'f',
2297+
range: {
2298+
end: {
2299+
character: 11,
2300+
line: 1
2301+
},
2302+
start: {
2303+
character: 10,
2304+
line: 1
2305+
}
2306+
}
2307+
}]
2308+
}
2309+
});
2310+
});
2311+
});
2312+
21752313
describe('Special file names', function (this: TestContext) {
21762314

21772315
beforeEach(initializeTypeScriptService(createService, rootUri, new Map([

src/test/util-test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import * as assert from 'assert';
2-
import { getMatchScore, isGlobalTSFile, isSymbolDescriptorMatch } from '../util';
2+
import { getMatchScore, isGlobalTSFile, isSymbolDescriptorMatch, JSONPTR } from '../util';
33

44
describe('util', () => {
5+
describe('JSONPTR', () => {
6+
it('should escape JSON Pointer components', () => {
7+
const uri = 'file:///foo/~bar';
8+
const pointer = JSONPTR`/changes/${uri}/-`;
9+
assert.equal(pointer, '/changes/file:~1~1~1foo~1~0bar/-');
10+
});
11+
});
512
describe('getMatchScore()', () => {
613
it('should return a score of 4 if 4 properties match', () => {
714
const score = getMatchScore({

src/typescript-service.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@ import {
1919
MarkedString,
2020
ParameterInformation,
2121
ReferenceParams,
22+
RenameParams,
2223
SignatureHelp,
2324
SignatureInformation,
2425
SymbolInformation,
2526
TextDocumentPositionParams,
26-
TextDocumentSyncKind
27+
TextDocumentSyncKind,
28+
TextEdit,
29+
WorkspaceEdit
2730
} from 'vscode-languageserver';
2831
import { walkMostAST } from './ast';
2932
import { convertTsDiagnostic } from './diagnostics';
@@ -49,6 +52,7 @@ import {
4952
getMatchScore,
5053
isLocalUri,
5154
isSymbolDescriptorMatch,
55+
JSONPTR,
5256
normalizeUri,
5357
path2uri,
5458
toUnixPath,
@@ -986,6 +990,61 @@ export class TypeScriptService {
986990
.map(signatureHelp => ({ op: 'add', path: '', value: signatureHelp }) as AddPatch);
987991
}
988992

993+
/**
994+
* The rename request is sent from the client to the server to perform a workspace-wide rename of a symbol.
995+
*
996+
* @return Observable of JSON Patches that build a `WorkspaceEdit` result
997+
*/
998+
textDocumentRename(params: RenameParams, span = new Span()): Observable<OpPatch> {
999+
const uri = normalizeUri(params.textDocument.uri);
1000+
const editUris = new Set<string>();
1001+
return Observable.fromPromise(this.projectManager.ensureOwnFiles(span))
1002+
.mergeMap(() => {
1003+
1004+
const filePath = uri2path(uri);
1005+
const configuration = this.projectManager.getParentConfiguration(params.textDocument.uri);
1006+
if (!configuration) {
1007+
throw new Error(`tsconfig.json not found for ${filePath}`);
1008+
}
1009+
configuration.ensureAllFiles(span);
1010+
1011+
const sourceFile = this._getSourceFile(configuration, filePath, span);
1012+
if (!sourceFile) {
1013+
throw new Error(`Expected source file ${filePath} to exist in configuration`);
1014+
}
1015+
1016+
const position = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character);
1017+
1018+
const renameInfo = configuration.getService().getRenameInfo(filePath, position);
1019+
if (!renameInfo.canRename) {
1020+
throw new Error('This symbol cannot be renamed');
1021+
}
1022+
1023+
return Observable.from(configuration.getService().findRenameLocations(filePath, position, false, true))
1024+
.map((location: ts.RenameLocation): [string, TextEdit] => {
1025+
const sourceFile = this._getSourceFile(configuration, location.fileName, span);
1026+
if (!sourceFile) {
1027+
throw new Error(`expected source file ${location.fileName} to exist in configuration`);
1028+
}
1029+
const editUri = path2uri(this.root, location.fileName);
1030+
const start = ts.getLineAndCharacterOfPosition(sourceFile, location.textSpan.start);
1031+
const end = ts.getLineAndCharacterOfPosition(sourceFile, location.textSpan.start + location.textSpan.length);
1032+
const edit: TextEdit = { range: { start, end }, newText: params.newName };
1033+
return [editUri, edit];
1034+
});
1035+
})
1036+
.map(([uri, edit]): AddPatch => {
1037+
// if file has no edit yet, initialize array
1038+
if (!editUris.has(uri)) {
1039+
editUris.add(uri);
1040+
return { op: 'add', path: JSONPTR`/changes/${uri}`, value: [edit] };
1041+
}
1042+
// else append to array
1043+
return { op: 'add', path: JSONPTR`/changes/${uri}/-`, value: edit };
1044+
})
1045+
.startWith({ op: 'add', path: '', value: { changes: {} } as WorkspaceEdit } as AddPatch);
1046+
}
1047+
9891048
/**
9901049
* The document open notification is sent from the client to the server to signal newly opened
9911050
* text documents. The document's truth is now managed by the client and the server must not try

src/util.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import { PackageDescriptor, SymbolDescriptor } from './request-type';
77

88
let strict = false;
99

10+
/**
11+
* Template string tag to escape JSON Pointer components as per https://tools.ietf.org/html/rfc6901#section-3
12+
*/
13+
export function JSONPTR(strings: TemplateStringsArray, ...toEscape: string[]): string {
14+
return strings.reduce((prev, curr, i) => prev + toEscape[i - 1].replace(/~/g, '~0').replace(/\//g, '~1') + curr);
15+
}
16+
1017
/**
1118
* Toggles "strict" flag, affects how we are parsing/generating URLs.
1219
* In strict mode we using "file://PATH", otherwise on Windows we are using "file:///PATH"

0 commit comments

Comments
 (0)