Skip to content

Commit a5a0676

Browse files
author
Andy Hanson
committed
getEditsForFileRename: Test both before and after the rename
1 parent e060871 commit a5a0676

20 files changed

+206
-123
lines changed

src/compiler/moduleSpecifiers.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@
22
/* @internal */
33
namespace ts.moduleSpecifiers {
44
export interface ModuleSpecifierPreferences {
5-
importModuleSpecifierPreference?: "relative" | "non-relative";
5+
readonly importModuleSpecifierPreference?: "relative" | "non-relative";
66
}
77

8-
// Note: fromSourceFile is just for usesJsExtensionOnImports
9-
export function getModuleSpecifier(compilerOptions: CompilerOptions, fromSourceFile: SourceFile, fromSourceFileName: string, toFileName: string, host: ModuleSpecifierResolutionHost, preferences: ModuleSpecifierPreferences = {}) {
10-
const info = getInfo(compilerOptions, fromSourceFile, fromSourceFileName, host);
11-
return getGlobalModuleSpecifier(toFileName, info, host, compilerOptions) ||
8+
// Note: importingSourceFile is just for usesJsExtensionOnImports
9+
export function getModuleSpecifier(
10+
compilerOptions: CompilerOptions,
11+
importingSourceFile: SourceFile,
12+
importingSourceFileName: string,
13+
toFileName: string,
14+
host: ModuleSpecifierResolutionHost,
15+
files: ReadonlyArray<SourceFile>,
16+
preferences: ModuleSpecifierPreferences = {},
17+
): string {
18+
const info = getInfo(compilerOptions, importingSourceFile, importingSourceFileName, host);
19+
const modulePaths = getAllModulePaths(files, toFileName, info.getCanonicalFileName, host);
20+
return firstDefined(modulePaths, moduleFileName => getGlobalModuleSpecifier(moduleFileName, info, host, compilerOptions)) ||
21+
getGlobalModuleSpecifier(toFileName, info, host, compilerOptions) ||
1222
first(getLocalModuleSpecifiers(toFileName, info, compilerOptions, preferences));
1323
}
1424

@@ -28,7 +38,7 @@ namespace ts.moduleSpecifiers {
2838
if (!files) {
2939
return Debug.fail("Files list must be present to resolve symlinks in specifier resolution");
3040
}
31-
const modulePaths = getAllModulePaths(files, getSourceFileOfNode(moduleSymbol.valueDeclaration), info.getCanonicalFileName, host);
41+
const modulePaths = getAllModulePaths(files, getSourceFileOfNode(moduleSymbol.valueDeclaration).fileName, info.getCanonicalFileName, host);
3242

3343
const global = mapDefined(modulePaths, moduleFileName => getGlobalModuleSpecifier(moduleFileName, info, host, compilerOptions));
3444
return global.length ? global.map(g => [g]) : modulePaths.map(moduleFileName =>
@@ -177,14 +187,14 @@ namespace ts.moduleSpecifiers {
177187
}
178188

179189
/**
180-
* Looks for a existing imports that use symlinks to this module.
190+
* Looks for existing imports that use symlinks to this module.
181191
* Only if no symlink is available, the real path will be used.
182192
*/
183-
function getAllModulePaths(files: ReadonlyArray<SourceFile>, { fileName }: SourceFile, getCanonicalFileName: (file: string) => string, host: ModuleSpecifierResolutionHost): ReadonlyArray<string> {
193+
function getAllModulePaths(files: ReadonlyArray<SourceFile>, importedFileName: string, getCanonicalFileName: (file: string) => string, host: ModuleSpecifierResolutionHost): ReadonlyArray<string> {
184194
const symlinks = mapDefined(files, sf =>
185195
sf.resolvedModules && firstDefinedIterator(sf.resolvedModules.values(), res =>
186-
res && res.resolvedFileName === fileName ? res.originalPath : undefined));
187-
return symlinks.length === 0 ? getAllModulePathsUsingIndirectSymlinks(files, getNormalizedAbsolutePath(fileName, host.getCurrentDirectory ? host.getCurrentDirectory() : ""), getCanonicalFileName, host) : symlinks;
196+
res && res.resolvedFileName === importedFileName ? res.originalPath : undefined));
197+
return symlinks.length === 0 ? getAllModulePathsUsingIndirectSymlinks(files, getNormalizedAbsolutePath(importedFileName, host.getCurrentDirectory ? host.getCurrentDirectory() : ""), getCanonicalFileName, host) : symlinks;
188198
}
189199

190200
function getRelativePathNParents(relativePath: string): number {

src/harness/fourslash.ts

Lines changed: 72 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -391,8 +391,11 @@ namespace FourSlash {
391391
}
392392

393393
private getFileContent(fileName: string): string {
394-
const script = this.languageServiceAdapterHost.getScriptInfo(fileName)!;
395-
return script.content;
394+
return ts.Debug.assertDefined(this.tryGetFileContent(fileName));
395+
}
396+
private tryGetFileContent(fileName: string): string | undefined {
397+
const script = this.languageServiceAdapterHost.getScriptInfo(fileName);
398+
return script && script.content;
396399
}
397400

398401
// Entry points from fourslash.ts
@@ -1956,7 +1959,7 @@ Actual: ${stringify(fullActual)}`);
19561959
* @returns The number of characters added to the file as a result of the edits.
19571960
* May be negative.
19581961
*/
1959-
private applyEdits(fileName: string, edits: ts.TextChange[], isFormattingEdit: boolean): number {
1962+
private applyEdits(fileName: string, edits: ReadonlyArray<ts.TextChange>, isFormattingEdit: boolean): number {
19601963
// We get back a set of edits, but langSvc.editScript only accepts one at a time. Use this to keep track
19611964
// of the incremental offset from each edit to the next. We assume these edit ranges don't overlap
19621965

@@ -3134,30 +3137,34 @@ Actual: ${stringify(fullActual)}`);
31343137
assert(action.name === "Move to a new file" && action.description === "Move to a new file");
31353138

31363139
const editInfo = this.languageService.getEditsForRefactor(this.activeFile.fileName, this.formatCodeSettings, range, refactor.name, action.name, options.preferences || ts.defaultPreferences)!;
3137-
this.testNewFileContents(editInfo.edits, options.newFileContents);
3140+
this.testNewFileContents(editInfo.edits, options.newFileContents, "move to new file");
31383141
}
31393142

3140-
private testNewFileContents(edits: ReadonlyArray<ts.FileTextChanges>, newFileContents: { [fileName: string]: string }): void {
3141-
for (const edit of edits) {
3142-
const newContent = newFileContents[edit.fileName];
3143+
private testNewFileContents(edits: ReadonlyArray<ts.FileTextChanges>, newFileContents: { [fileName: string]: string }, description: string): void {
3144+
for (const { fileName, textChanges } of edits) {
3145+
const newContent = newFileContents[fileName];
31433146
if (newContent === undefined) {
3144-
this.raiseError(`There was an edit in ${edit.fileName} but new content was not specified.`);
3147+
this.raiseError(`${description} - There was an edit in ${fileName} but new content was not specified.`);
31453148
}
3146-
if (this.testData.files.some(f => f.fileName === edit.fileName)) {
3147-
this.applyEdits(edit.fileName, edit.textChanges, /*isFormattingEdit*/ false);
3148-
this.openFile(edit.fileName);
3149-
this.verifyCurrentFileContent(newContent);
3149+
3150+
const fileContent = this.tryGetFileContent(fileName);
3151+
if (fileContent !== undefined) {
3152+
const actualNewContent = ts.textChanges.applyChanges(fileContent, textChanges);
3153+
assert.equal(actualNewContent, newContent, `new content for ${fileName}`);
31503154
}
31513155
else {
3152-
assert(edit.textChanges.length === 1);
3153-
const change = ts.first(edit.textChanges);
3156+
// Creates a new file.
3157+
assert(textChanges.length === 1);
3158+
const change = ts.first(textChanges);
31543159
assert.deepEqual(change.span, ts.createTextSpan(0, 0));
3155-
assert.equal(change.newText, newContent, `Content for ${edit.fileName}`);
3160+
assert.equal(change.newText, newContent, `${description} - Content for ${fileName}`);
31563161
}
31573162
}
31583163

31593164
for (const fileName in newFileContents) {
3160-
assert(edits.some(e => e.fileName === fileName));
3165+
if (!edits.some(e => e.fileName === fileName)) {
3166+
ts.Debug.fail(`${description} - Asserted new contents of ${fileName} but there were no edits`);
3167+
}
31613168
}
31623169
}
31633170

@@ -3292,7 +3299,7 @@ Actual: ${stringify(fullActual)}`);
32923299
eq(item.replacementSpan, options && options.replacementSpan && ts.createTextSpanFromRange(options.replacementSpan), "replacementSpan");
32933300
}
32943301

3295-
private findFile(indexOrName: string | number) {
3302+
private findFile(indexOrName: string | number): FourSlashFile {
32963303
if (typeof indexOrName === "number") {
32973304
const index = indexOrName;
32983305
if (index >= this.testData.files.length) {
@@ -3303,32 +3310,39 @@ Actual: ${stringify(fullActual)}`);
33033310
}
33043311
}
33053312
else if (ts.isString(indexOrName)) {
3306-
let name = ts.normalizePath(indexOrName);
3307-
3308-
// names are stored in the compiler with this relative path, this allows people to use goTo.file on just the fileName
3309-
name = name.indexOf("/") === -1 ? (this.basePath + "/" + name) : name;
3310-
3311-
const availableNames: string[] = [];
3312-
const result = ts.forEach(this.testData.files, file => {
3313-
const fn = ts.normalizePath(file.fileName);
3314-
if (fn) {
3315-
if (fn === name) {
3316-
return file;
3317-
}
3318-
availableNames.push(fn);
3319-
}
3320-
});
3321-
3322-
if (!result) {
3323-
throw new Error(`No test file named "${name}" exists. Available file names are: ${availableNames.join(", ")}`);
3313+
const { file, availableNames } = this.tryFindFileWorker(indexOrName);
3314+
if (!file) {
3315+
throw new Error(`No test file named "${indexOrName}" exists. Available file names are: ${availableNames.join(", ")}`);
33243316
}
3325-
return result;
3317+
return file;
33263318
}
33273319
else {
33283320
return ts.Debug.assertNever(indexOrName);
33293321
}
33303322
}
33313323

3324+
private tryFindFileWorker(name: string): { readonly file: FourSlashFile | undefined; readonly availableNames: ReadonlyArray<string>; } {
3325+
name = ts.normalizePath(name);
3326+
// names are stored in the compiler with this relative path, this allows people to use goTo.file on just the fileName
3327+
name = name.indexOf("/") === -1 ? (this.basePath + "/" + name) : name;
3328+
3329+
const availableNames: string[] = [];
3330+
const file = ts.forEach(this.testData.files, file => {
3331+
const fn = ts.normalizePath(file.fileName);
3332+
if (fn) {
3333+
if (fn === name) {
3334+
return file;
3335+
}
3336+
availableNames.push(fn);
3337+
}
3338+
});
3339+
return { file, availableNames };
3340+
}
3341+
3342+
private hasFile(name: string): boolean {
3343+
return this.tryFindFileWorker(name).file !== undefined;
3344+
}
3345+
33323346
private getLineColStringAtPosition(position: number) {
33333347
const pos = this.languageServiceAdapterHost.positionToLineAndCharacter(this.activeFile.fileName, position);
33343348
return `line ${(pos.line + 1)}, col ${pos.character}`;
@@ -3366,16 +3380,35 @@ Actual: ${stringify(fullActual)}`);
33663380
return !!a && !!b && a.start === b.start && a.length === b.length;
33673381
}
33683382

3369-
public getEditsForFileRename(options: FourSlashInterface.GetEditsForFileRenameOptions): void {
3370-
const changes = this.languageService.getEditsForFileRename(options.oldPath, options.newPath, this.formatCodeSettings, ts.defaultPreferences);
3371-
this.testNewFileContents(changes, options.newFileContents);
3383+
public getEditsForFileRename({ oldPath, newPath, newFileContents }: FourSlashInterface.GetEditsForFileRenameOptions): void {
3384+
const test = (fileContents: { readonly [fileName: string]: string }, description: string): void => {
3385+
const changes = this.languageService.getEditsForFileRename(oldPath, newPath, this.formatCodeSettings, ts.defaultPreferences);
3386+
this.testNewFileContents(changes, fileContents, description);
3387+
};
3388+
3389+
ts.Debug.assert(!this.hasFile(newPath), "initially, newPath should not exist");
3390+
3391+
test(newFileContents, "with file not yet moved");
3392+
3393+
this.languageServiceAdapterHost.renameFileOrDirectory(oldPath, newPath);
3394+
this.languageService.cleanupSemanticCache();
3395+
const pathUpdater = ts.getPathUpdater(oldPath, newPath, ts.createGetCanonicalFileName(/*useCaseSensitiveFileNames*/ false));
3396+
test(renameKeys(newFileContents, key => pathUpdater(key) || key), "with file moved");
33723397
}
33733398

33743399
private getApplicableRefactors(positionOrRange: number | ts.TextRange, preferences = ts.defaultPreferences): ReadonlyArray<ts.ApplicableRefactorInfo> {
33753400
return this.languageService.getApplicableRefactors(this.activeFile.fileName, positionOrRange, preferences) || ts.emptyArray;
33763401
}
33773402
}
33783403

3404+
function renameKeys<T>(obj: { readonly [key: string]: T }, renameKey: (key: string) => string): { readonly [key: string]: T } {
3405+
const res: { [key: string]: T } = {};
3406+
for (const key in obj) {
3407+
res[renameKey(key)] = obj[key];
3408+
}
3409+
return res;
3410+
}
3411+
33793412
export function runFourSlashTest(basePath: string, testType: FourSlashTestType, fileName: string) {
33803413
const content = Harness.IO.readFile(fileName)!;
33813414
runFourSlashTestContent(basePath, testType, content, fileName);

src/harness/harnessLanguageService.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,21 @@ namespace Harness.LanguageService {
151151
this.scriptInfos.set(vpath.resolve(this.vfs.cwd(), fileName), new ScriptInfo(fileName, content, isRootFile));
152152
}
153153

154+
public renameFileOrDirectory(oldPath: string, newPath: string): void {
155+
this.vfs.mkdirpSync(ts.getDirectoryPath(newPath));
156+
this.vfs.renameSync(oldPath, newPath);
157+
158+
const updater = ts.getPathUpdater(oldPath, newPath, ts.createGetCanonicalFileName(/*useCaseSensitiveFileNames*/ false));
159+
this.scriptInfos.forEach((scriptInfo, key) => {
160+
const newFileName = updater(key);
161+
if (newFileName !== undefined) {
162+
this.scriptInfos.delete(key);
163+
this.scriptInfos.set(newFileName, scriptInfo);
164+
scriptInfo.fileName = newFileName;
165+
}
166+
});
167+
}
168+
154169
public editScript(fileName: string, start: number, end: number, newText: string) {
155170
const script = this.getScriptInfo(fileName);
156171
if (script) {

src/server/session.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,8 @@ namespace ts.server {
129129
project: Project;
130130
}
131131

132-
function allEditsBeforePos(edits: TextChange[], pos: number) {
133-
for (const edit of edits) {
134-
if (textSpanEnd(edit.span) >= pos) {
135-
return false;
136-
}
137-
}
138-
return true;
132+
function allEditsBeforePos(edits: ReadonlyArray<TextChange>, pos: number): boolean {
133+
return edits.every(edit => textSpanEnd(edit.span) < pos);
139134
}
140135

141136
// CommandNames used to be exposed before TS 2.4 as a namespace

src/services/getEditsForFileRename.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ namespace ts {
1313

1414
/** If 'path' refers to an old directory, returns path in the new directory. */
1515
type PathUpdater = (path: string) => string | undefined;
16-
function getPathUpdater(oldFileOrDirPath: string, newFileOrDirPath: string, getCanonicalFileName: GetCanonicalFileName): PathUpdater {
16+
// exported for tests
17+
export function getPathUpdater(oldFileOrDirPath: string, newFileOrDirPath: string, getCanonicalFileName: GetCanonicalFileName): PathUpdater {
1718
const canonicalOldPath = getCanonicalFileName(oldFileOrDirPath);
1819
return path => {
1920
const canonicalPath = getCanonicalFileName(path);
@@ -102,7 +103,8 @@ namespace ts {
102103
getCanonicalFileName: GetCanonicalFileName,
103104
preferences: UserPreferences,
104105
): void {
105-
for (const sourceFile of program.getSourceFiles()) {
106+
const allFiles = program.getSourceFiles();
107+
for (const sourceFile of allFiles) {
106108
const newFromOld = oldToNew(sourceFile.fileName);
107109
const newImportFromPath = newFromOld !== undefined ? newFromOld : sourceFile.fileName;
108110
const newImportFromDirectory = getDirectoryPath(newImportFromPath);
@@ -121,15 +123,19 @@ namespace ts {
121123
return newAbsolute === undefined ? undefined : ensurePathIsNonModuleName(getRelativePathFromDirectory(newImportFromDirectory, newAbsolute, getCanonicalFileName));
122124
},
123125
importLiteral => {
126+
const importedModuleSymbol = program.getTypeChecker().getSymbolAtLocation(importLiteral);
127+
// No need to update if it's an ambient module^M
128+
if (importedModuleSymbol && importedModuleSymbol.declarations.some(d => isAmbientModule(d))) return undefined;
129+
124130
const toImport = oldFromNew !== undefined
125131
// If we're at the new location (file was already renamed), need to redo module resolution starting from the old location.
126132
// TODO:GH#18217
127133
? getSourceFileToImportFromResolved(resolveModuleName(importLiteral.text, oldImportFromPath, program.getCompilerOptions(), host as ModuleResolutionHost), oldToNew, program)
128-
: getSourceFileToImport(importLiteral, sourceFile, program, host, oldToNew);
134+
: getSourceFileToImport(importedModuleSymbol, importLiteral, sourceFile, program, host, oldToNew);
129135
// If neither the importing source file nor the imported file moved, do nothing.
130136
return toImport === undefined || !toImport.updated && !importingSourceFileMoved
131137
? undefined
132-
: moduleSpecifiers.getModuleSpecifier(program.getCompilerOptions(), sourceFile, newImportFromPath, toImport.newFileName, host, preferences);
138+
: moduleSpecifiers.getModuleSpecifier(program.getCompilerOptions(), sourceFile, newImportFromPath, toImport.newFileName, host, allFiles, preferences);
133139
});
134140
}
135141
}
@@ -146,11 +152,17 @@ namespace ts {
146152
/** True if the imported file was renamed. */
147153
readonly updated: boolean;
148154
}
149-
function getSourceFileToImport(importLiteral: StringLiteralLike, importingSourceFile: SourceFile, program: Program, host: LanguageServiceHost, oldToNew: PathUpdater): ToImport | undefined {
150-
const symbol = program.getTypeChecker().getSymbolAtLocation(importLiteral);
151-
if (symbol) {
152-
if (symbol.declarations.some(d => isAmbientModule(d))) return undefined; // No need to update if it's an ambient module
153-
const oldFileName = find(symbol.declarations, isSourceFile)!.fileName;
155+
function getSourceFileToImport(
156+
importedModuleSymbol: Symbol | undefined,
157+
importLiteral: StringLiteralLike,
158+
importingSourceFile: SourceFile,
159+
program: Program,
160+
host: LanguageServiceHost,
161+
oldToNew: PathUpdater,
162+
): ToImport | undefined {
163+
if (importedModuleSymbol) {
164+
// `find` should succeed because we checked for ambient modules before calling this function.
165+
const oldFileName = find(importedModuleSymbol.declarations, isSourceFile)!.fileName;
154166
const newFileName = oldToNew(oldFileName);
155167
return newFileName === undefined ? { newFileName: oldFileName, updated: false } : { newFileName, updated: true };
156168
}

0 commit comments

Comments
 (0)