From 0b4c9ba3c4aaf238a0ebc8811aaa635bdbaf0520 Mon Sep 17 00:00:00 2001 From: Andy Hanson Date: Mon, 23 Apr 2018 15:02:04 -0700 Subject: [PATCH] Add 'move to new file' refactor --- src/compiler/core.ts | 4 +- src/compiler/diagnosticMessages.json | 4 + src/harness/fourslash.ts | 61 ++- src/harness/tsconfig.json | 1 + src/server/tsconfig.json | 1 + src/server/tsconfig.library.json | 1 + src/services/codefixes/convertToEs6Module.ts | 9 - .../codefixes/fixInvalidImportSyntax.ts | 2 +- src/services/codefixes/useDefaultImport.ts | 2 +- src/services/importTracker.ts | 15 +- src/services/refactorProvider.ts | 4 +- src/services/refactors/extractSymbol.ts | 4 +- src/services/refactors/moveToNewFile.ts | 359 ++++++++++++++++++ src/services/textChanges.ts | 20 +- src/services/tsconfig.json | 1 + src/services/utilities.ts | 34 ++ tests/cases/fourslash/fourslash.ts | 5 +- tests/cases/fourslash/moveToNewFile.ts | 23 ++ .../moveToNewFile_declarationKinds.ts | 38 ++ .../fourslash/moveToNewFile_defaultExport.ts | 25 ++ .../fourslash/moveToNewFile_defaultImport.ts | 17 + tests/cases/fourslash/moveToNewFile_global.ts | 16 + .../cases/fourslash/moveToNewFile_multiple.ts | 28 ++ .../moveToNewFile_newModuleNameUnique.ts | 20 + .../fourslash/moveToNewFile_onlyStatements.ts | 16 + .../fourslash/moveToNewFile_rangeInvalid.ts | 13 + .../fourslash/moveToNewFile_rangeSemiValid.ts | 19 + .../fourslash/moveToNewFile_updateUses.ts | 25 ++ 28 files changed, 722 insertions(+), 45 deletions(-) create mode 100644 src/services/refactors/moveToNewFile.ts create mode 100644 tests/cases/fourslash/moveToNewFile.ts create mode 100644 tests/cases/fourslash/moveToNewFile_declarationKinds.ts create mode 100644 tests/cases/fourslash/moveToNewFile_defaultExport.ts create mode 100644 tests/cases/fourslash/moveToNewFile_defaultImport.ts create mode 100644 tests/cases/fourslash/moveToNewFile_global.ts create mode 100644 tests/cases/fourslash/moveToNewFile_multiple.ts create mode 100644 tests/cases/fourslash/moveToNewFile_newModuleNameUnique.ts create mode 100644 tests/cases/fourslash/moveToNewFile_onlyStatements.ts create mode 100644 tests/cases/fourslash/moveToNewFile_rangeInvalid.ts create mode 100644 tests/cases/fourslash/moveToNewFile_rangeSemiValid.ts create mode 100644 tests/cases/fourslash/moveToNewFile_updateUses.ts diff --git a/src/compiler/core.ts b/src/compiler/core.ts index f0220fb8c93a5..034725bf0d0f2 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -311,8 +311,8 @@ namespace ts { } /** Works like Array.prototype.findIndex, returning `-1` if no element satisfying the predicate is found. */ - export function findIndex(array: ReadonlyArray, predicate: (element: T, index: number) => boolean): number { - for (let i = 0; i < array.length; i++) { + export function findIndex(array: ReadonlyArray, predicate: (element: T, index: number) => boolean, startIndex = 0): number { + for (let i = startIndex; i < array.length; i++) { if (predicate(array[i], i)) { return i; } diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index 2bb6d4bec61d2..7a7053867c2de 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -4165,5 +4165,9 @@ "Generate 'get' and 'set' accessors": { "category": "Message", "code": 95046 + }, + "Move to new file": { + "category": "Message", + "code": 95047 } } diff --git a/src/harness/fourslash.ts b/src/harness/fourslash.ts index 7dfff7dd18995..ca0051e0bb978 100644 --- a/src/harness/fourslash.ts +++ b/src/harness/fourslash.ts @@ -2950,9 +2950,7 @@ Actual: ${stringify(fullActual)}`); } public verifyApplicableRefactorAvailableAtMarker(negative: boolean, markerName: string) { - const marker = this.getMarkerByName(markerName); - const applicableRefactors = this.languageService.getApplicableRefactors(this.activeFile.fileName, marker.position, ts.defaultPreferences); - const isAvailable = applicableRefactors && applicableRefactors.length > 0; + const isAvailable = this.getApplicableRefactors(this.getMarkerByName(markerName).position).length > 0; if (negative && isAvailable) { this.raiseError(`verifyApplicableRefactorAvailableAtMarker failed - expected no refactor at marker ${markerName} but found some.`); } @@ -2969,9 +2967,7 @@ Actual: ${stringify(fullActual)}`); } public verifyRefactorAvailable(negative: boolean, name: string, actionName?: string) { - const selection = this.getSelection(); - - let refactors = this.languageService.getApplicableRefactors(this.activeFile.fileName, selection, ts.defaultPreferences) || []; + let refactors = this.getApplicableRefactors(this.getSelection()); refactors = refactors.filter(r => r.name === name && (actionName === undefined || r.actions.some(a => a.name === actionName))); const isAvailable = refactors.length > 0; @@ -2991,10 +2987,7 @@ Actual: ${stringify(fullActual)}`); } public verifyRefactor({ name, actionName, refactors }: FourSlashInterface.VerifyRefactorOptions) { - const selection = this.getSelection(); - - const actualRefactors = (this.languageService.getApplicableRefactors(this.activeFile.fileName, selection, ts.defaultPreferences) || ts.emptyArray) - .filter(r => r.name === name && r.actions.some(a => a.name === actionName)); + const actualRefactors = this.getApplicableRefactors(this.getSelection()).filter(r => r.name === name && r.actions.some(a => a.name === actionName)); this.assertObjectsEqual(actualRefactors, refactors); } @@ -3004,8 +2997,7 @@ Actual: ${stringify(fullActual)}`); throw new Error("Exactly one refactor range is allowed per test."); } - const applicableRefactors = this.languageService.getApplicableRefactors(this.activeFile.fileName, ts.first(ranges), ts.defaultPreferences); - const isAvailable = applicableRefactors && applicableRefactors.length > 0; + const isAvailable = this.getApplicableRefactors(ts.first(ranges)).length > 0; if (negative && isAvailable) { this.raiseError(`verifyApplicableRefactorAvailableForRange failed - expected no refactor but found some.`); } @@ -3016,7 +3008,7 @@ Actual: ${stringify(fullActual)}`); public applyRefactor({ refactorName, actionName, actionDescription, newContent: newContentWithRenameMarker }: FourSlashInterface.ApplyRefactorOptions) { const range = this.getSelection(); - const refactors = this.languageService.getApplicableRefactors(this.activeFile.fileName, range, ts.defaultPreferences); + const refactors = this.getApplicableRefactors(range); const refactorsWithName = refactors.filter(r => r.name === refactorName); if (refactorsWithName.length === 0) { this.raiseError(`The expected refactor: ${refactorName} is not available at the marker location.\nAvailable refactors: ${refactors.map(r => r.name)}`); @@ -3062,7 +3054,38 @@ Actual: ${stringify(fullActual)}`); return { renamePosition, newContent }; } } + } + + public moveToNewFile(options: FourSlashInterface.MoveToNewFileOptions): void { + assert(this.getRanges().length === 1); + const range = this.getRanges()[0]; + const refactor = ts.find(this.getApplicableRefactors(range), r => r.name === "Move to new file"); + assert(refactor.actions.length === 1); + const action = ts.first(refactor.actions); + assert(action.name === "Move to new file" && action.description === "Move to new file"); + + const editInfo = this.languageService.getEditsForRefactor(this.activeFile.fileName, this.formatCodeSettings, range, refactor.name, action.name, ts.defaultPreferences); + for (const edit of editInfo.edits) { + const newContent = options.newFileContents[edit.fileName]; + if (newContent === undefined) { + this.raiseError(`There was an edit in ${edit.fileName} but new content was not specified.`); + } + if (this.testData.files.some(f => f.fileName === edit.fileName)) { + this.applyEdits(edit.fileName, edit.textChanges, /*isFormattingEdit*/ false); + this.openFile(edit.fileName); + this.verifyCurrentFileContent(newContent); + } + else { + assert(edit.textChanges.length === 1); + const change = ts.first(edit.textChanges); + assert.deepEqual(change.span, ts.createTextSpan(0, 0)); + assert.equal(change.newText, newContent, `Content for ${edit.fileName}`); + } + } + for (const fileName in options.newFileContents) { + assert(editInfo.edits.some(e => e.fileName === fileName)); + } } public verifyFileAfterApplyingRefactorAtMarker( @@ -3278,6 +3301,10 @@ Actual: ${stringify(fullActual)}`); this.verifyCurrentFileContent(options.newFileContents[fileName]); } } + + private getApplicableRefactors(positionOrRange: number | ts.TextRange): ReadonlyArray { + return this.languageService.getApplicableRefactors(this.activeFile.fileName, positionOrRange, ts.defaultPreferences) || ts.emptyArray; + } } export function runFourSlashTest(basePath: string, testType: FourSlashTestType, fileName: string) { @@ -4373,6 +4400,10 @@ namespace FourSlashInterface { public getEditsForFileRename(options: GetEditsForFileRenameOptions) { this.state.getEditsForFileRename(options); } + + public moveToNewFile(options: MoveToNewFileOptions): void { + this.state.moveToNewFile(options); + } } export class Edit { @@ -4721,4 +4752,8 @@ namespace FourSlashInterface { readonly newPath: string; readonly newFileContents: { readonly [fileName: string]: string }; } + + export interface MoveToNewFileOptions { + readonly newFileContents: { readonly [fileName: string]: string }; + } } diff --git a/src/harness/tsconfig.json b/src/harness/tsconfig.json index a4ad8cb37975f..44b83f257bbe6 100644 --- a/src/harness/tsconfig.json +++ b/src/harness/tsconfig.json @@ -115,6 +115,7 @@ "../services/codefixes/useDefaultImport.ts", "../services/refactors/extractSymbol.ts", "../services/refactors/generateGetAccessorAndSetAccessor.ts", + "../services/refactors/moveToNewFile.ts", "../services/sourcemaps.ts", "../services/services.ts", "../services/breakpoints.ts", diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json index e6768f4edeed1..7ab06cf0666a0 100644 --- a/src/server/tsconfig.json +++ b/src/server/tsconfig.json @@ -111,6 +111,7 @@ "../services/codefixes/useDefaultImport.ts", "../services/refactors/extractSymbol.ts", "../services/refactors/generateGetAccessorAndSetAccessor.ts", + "../services/refactors/moveToNewFile.ts", "../services/sourcemaps.ts", "../services/services.ts", "../services/breakpoints.ts", diff --git a/src/server/tsconfig.library.json b/src/server/tsconfig.library.json index a167d3b39a2ff..a57331f7d1550 100644 --- a/src/server/tsconfig.library.json +++ b/src/server/tsconfig.library.json @@ -117,6 +117,7 @@ "../services/codefixes/useDefaultImport.ts", "../services/refactors/extractSymbol.ts", "../services/refactors/generateGetAccessorAndSetAccessor.ts", + "../services/refactors/moveToNewFile.ts", "../services/sourcemaps.ts", "../services/services.ts", "../services/breakpoints.ts", diff --git a/src/services/codefixes/convertToEs6Module.ts b/src/services/codefixes/convertToEs6Module.ts index a8df21dc96d2d..39e8f1059dfc5 100644 --- a/src/services/codefixes/convertToEs6Module.ts +++ b/src/services/codefixes/convertToEs6Module.ts @@ -507,15 +507,6 @@ namespace ts.codefix { : makeImport(/*name*/ undefined, [makeImportSpecifier(propertyName, localName)], moduleSpecifier); } - function makeImport(name: Identifier | undefined, namedImports: ReadonlyArray | undefined, moduleSpecifier: StringLiteralLike): ImportDeclaration { - return makeImportDeclaration(name, namedImports, moduleSpecifier); - } - - export function makeImportDeclaration(name: Identifier, namedImports: ReadonlyArray | undefined, moduleSpecifier: Expression) { - const importClause = (name || namedImports) && createImportClause(name, namedImports && createNamedImports(namedImports)); - return createImportDeclaration(/*decorators*/ undefined, /*modifiers*/ undefined, importClause, moduleSpecifier); - } - function makeImportSpecifier(propertyName: string | undefined, name: string): ImportSpecifier { return createImportSpecifier(propertyName !== undefined && propertyName !== name ? createIdentifier(propertyName) : undefined, createIdentifier(name)); } diff --git a/src/services/codefixes/fixInvalidImportSyntax.ts b/src/services/codefixes/fixInvalidImportSyntax.ts index 50bf41360bce3..89a9aca461cb5 100644 --- a/src/services/codefixes/fixInvalidImportSyntax.ts +++ b/src/services/codefixes/fixInvalidImportSyntax.ts @@ -28,7 +28,7 @@ namespace ts.codefix { const variations: CodeFixAction[] = []; // import Bluebird from "bluebird"; - variations.push(createAction(context, sourceFile, node, makeImportDeclaration(namespace.name, /*namedImports*/ undefined, node.moduleSpecifier))); + variations.push(createAction(context, sourceFile, node, makeImport(namespace.name, /*namedImports*/ undefined, node.moduleSpecifier))); if (getEmitModuleKind(opts) === ModuleKind.CommonJS) { // import Bluebird = require("bluebird"); diff --git a/src/services/codefixes/useDefaultImport.ts b/src/services/codefixes/useDefaultImport.ts index 632cfc97e0075..99fc145b5630a 100644 --- a/src/services/codefixes/useDefaultImport.ts +++ b/src/services/codefixes/useDefaultImport.ts @@ -37,6 +37,6 @@ namespace ts.codefix { } function doChange(changes: textChanges.ChangeTracker, sourceFile: SourceFile, info: Info): void { - changes.replaceNode(sourceFile, info.importNode, makeImportDeclaration(info.name, /*namedImports*/ undefined, info.moduleSpecifier)); + changes.replaceNode(sourceFile, info.importNode, makeImport(info.name, /*namedImports*/ undefined, info.moduleSpecifier)); } } diff --git a/src/services/importTracker.ts b/src/services/importTracker.ts index 1a4b2a9f75195..5a4bc20676a68 100644 --- a/src/services/importTracker.ts +++ b/src/services/importTracker.ts @@ -248,7 +248,7 @@ namespace ts.FindAllReferences { const { name } = importClause; // If a default import has the same name as the default export, allow to rename it. // Given `import f` and `export default function f`, we will rename both, but for `import g` we will rename just that. - if (name && (!isForRename || name.escapedText === symbolName(exportSymbol))) { + if (name && (!isForRename || name.escapedText === symbolEscapedNameNoDefault(exportSymbol))) { const defaultImportAlias = checker.getSymbolAtLocation(name); addSearch(name, defaultImportAlias); } @@ -534,7 +534,7 @@ namespace ts.FindAllReferences { // If the import has a different name than the export, do not continue searching. // If `importedName` is undefined, do continue searching as the export is anonymous. // (All imports returned from this function will be ignored anyway if we are in rename and this is a not a named export.) - const importedName = symbolName(importedSymbol); + const importedName = symbolEscapedNameNoDefault(importedSymbol); if (importedName === undefined || importedName === InternalSymbolName.Default || importedName === symbol.escapedName) { return { kind: ImportExport.Import, symbol: importedSymbol, ...isImport }; } @@ -606,17 +606,6 @@ namespace ts.FindAllReferences { return isExternalModuleSymbol(exportingModuleSymbol) ? { exportingModuleSymbol, exportKind } : undefined; } - function symbolName(symbol: Symbol): __String | undefined { - if (symbol.escapedName !== InternalSymbolName.Default) { - return symbol.escapedName; - } - - return forEach(symbol.declarations, decl => { - const name = getNameOfDeclaration(decl); - return name && name.kind === SyntaxKind.Identifier && name.escapedText; - }); - } - /** If at an export specifier, go to the symbol it refers to. */ function skipExportSpecifierSymbol(symbol: Symbol, checker: TypeChecker): Symbol { // For `export { foo } from './bar", there's nothing to skip, because it does not create a new alias. But `export { foo } does. diff --git a/src/services/refactorProvider.ts b/src/services/refactorProvider.ts index f3504ed89b82d..adb3af3ea7adc 100644 --- a/src/services/refactorProvider.ts +++ b/src/services/refactorProvider.ts @@ -38,7 +38,7 @@ namespace ts { } } - export function getRefactorContextLength(context: RefactorContext): number { - return context.endPosition === undefined ? 0 : context.endPosition - context.startPosition; + export function getRefactorContextSpan({ startPosition, endPosition }: RefactorContext): TextSpan { + return createTextSpanFromBounds(startPosition, endPosition === undefined ? startPosition : endPosition); } } diff --git a/src/services/refactors/extractSymbol.ts b/src/services/refactors/extractSymbol.ts index 5805a9861801e..354c200217943 100644 --- a/src/services/refactors/extractSymbol.ts +++ b/src/services/refactors/extractSymbol.ts @@ -8,7 +8,7 @@ namespace ts.refactor.extractSymbol { * Exported for tests. */ export function getAvailableActions(context: RefactorContext): ApplicableRefactorInfo[] | undefined { - const rangeToExtract = getRangeToExtract(context.file, { start: context.startPosition, length: getRefactorContextLength(context) }); + const rangeToExtract = getRangeToExtract(context.file, getRefactorContextSpan(context)); const targetRange: TargetRange = rangeToExtract.targetRange; if (targetRange === undefined) { @@ -87,7 +87,7 @@ namespace ts.refactor.extractSymbol { /* Exported for tests */ export function getEditsForAction(context: RefactorContext, actionName: string): RefactorEditInfo | undefined { - const rangeToExtract = getRangeToExtract(context.file, { start: context.startPosition, length: getRefactorContextLength(context) }); + const rangeToExtract = getRangeToExtract(context.file, getRefactorContextSpan(context)); const targetRange: TargetRange = rangeToExtract.targetRange; const parsedFunctionIndexMatch = /^function_scope_(\d+)$/.exec(actionName); diff --git a/src/services/refactors/moveToNewFile.ts b/src/services/refactors/moveToNewFile.ts new file mode 100644 index 0000000000000..ad461d880d9e9 --- /dev/null +++ b/src/services/refactors/moveToNewFile.ts @@ -0,0 +1,359 @@ +/* @internal */ +namespace ts.refactor { + const refactorName = "Move to new file"; + registerRefactor(refactorName, { + getAvailableActions(context): ApplicableRefactorInfo[] { + if (getStatementsToMove(context) === undefined) return undefined; + const description = getLocaleSpecificMessage(Diagnostics.Move_to_new_file); + return [{ name: refactorName, description, actions: [{ name: refactorName, description }] }]; + }, + getEditsForAction(context, actionName): RefactorEditInfo { + Debug.assert(actionName === refactorName); + const statements = Debug.assertDefined(getStatementsToMove(context)); + const edits = textChanges.ChangeTracker.with(context, t => doChange(context.file, context.program, statements, t, context.host)); + return { edits, renameFilename: undefined, renameLocation: undefined }; + } + }); + + function getStatementsToMove(context: RefactorContext): ReadonlyArray | undefined { + const { file } = context; + const range = createTextRangeFromSpan(getRefactorContextSpan(context)); + const { statements } = file; + + const startNodeIndex = findIndex(statements, s => s.end > range.pos); + if (startNodeIndex === -1) return undefined; + // Can't only partially include the start node or be partially into the next node + const okStart = range.pos <= statements[startNodeIndex].getStart(file); + const afterEndNodeIndex = findIndex(statements, s => s.end > range.end, startNodeIndex); + // Can't be partially into the next node + const okEnd = afterEndNodeIndex === -1 || afterEndNodeIndex !== 0 && statements[afterEndNodeIndex].getStart(file) >= range.end; + return okStart && okEnd ? statements.slice(startNodeIndex, afterEndNodeIndex === -1 ? statements.length : afterEndNodeIndex) : undefined; + } + + function doChange(oldFile: SourceFile, program: Program, toMove: ReadonlyArray, changes: textChanges.ChangeTracker, host: LanguageServiceHost): void { + const checker = program.getTypeChecker(); + const usage = getUsageInfo(oldFile, toMove, checker); + + const currentDirectory = getDirectoryPath(oldFile.fileName); + const extension = extensionFromPath(oldFile.fileName); + const newModuleName = makeUniqueModuleName(getNewModuleName(usage.movedSymbols), extension, currentDirectory, host); + const newFileNameWithExtension = newModuleName + extension; + + // If previous file was global, this is easy. + changes.createNewFile(oldFile, combinePaths(currentDirectory, newFileNameWithExtension), getNewStatements(oldFile, usage, changes, toMove, program, newModuleName)); + } + + function getNewStatements( + oldFile: SourceFile, usage: UsageInfo, changes: textChanges.ChangeTracker, toMove: ReadonlyArray, program: Program, newModuleName: string, + ): ReadonlyArray { + const checker = program.getTypeChecker(); + + if (!oldFile.externalModuleIndicator) { + changes.deleteNodeRange(oldFile, first(toMove), last(toMove)); + return toMove; + } + + const importsFromNewFile = createOldFileImportsFromNewFile(usage.oldFileImportsFromNewFile, newModuleName); + if (importsFromNewFile) { + changes.insertNodeBefore(oldFile, oldFile.statements[0], importsFromNewFile, /*blankLineBetween*/ true); + } + + deleteUnusedOldImports(oldFile, toMove, changes, usage.unusedImportsFromOldFile, checker); + changes.deleteNodeRange(oldFile, first(toMove), last(toMove)); + + updateImportsInOtherFiles(changes, program, oldFile, usage.movedSymbols, newModuleName); + + return [ + ...getNewFileImportsAndAddExportInOldFile(oldFile, usage.oldImportsNeededByNewFile, usage.newFileImportsFromOldFile, changes, checker), + ...addExports(toMove, usage.oldFileImportsFromNewFile), + ]; + } + + function deleteUnusedOldImports(oldFile: SourceFile, toMove: ReadonlyArray, changes: textChanges.ChangeTracker, toDelete: ReadonlySymbolSet, checker: TypeChecker) { + for (const statement of oldFile.statements) { + if (!contains(toMove, statement) && isImportDeclaration(statement)) { + deleteUnusedImports(oldFile, statement, changes, name => toDelete.has(checker.getSymbolAtLocation(name))); + } + } + } + + function updateImportsInOtherFiles(changes: textChanges.ChangeTracker, program: Program, oldFile: SourceFile, movedSymbols: ReadonlySymbolSet, newModuleName: string): void { + const checker = program.getTypeChecker(); + for (const sourceFile of program.getSourceFiles()) { + if (sourceFile === oldFile) continue; + for (const statement of sourceFile.statements) { + if (!isImportDeclaration(statement) || !isStringLiteral(statement.moduleSpecifier)) continue; + + const shouldMove = (name: Identifier): boolean => movedSymbols.has(skipAlias(checker.getSymbolAtLocation(name), checker)); + deleteUnusedImports(sourceFile, statement, changes, shouldMove); + const newModuleSpecifier = combinePaths(getDirectoryPath(statement.moduleSpecifier.text), newModuleName); + const newImportDeclaration = filterImport(statement, createLiteral(newModuleSpecifier), shouldMove); + if (newImportDeclaration) changes.insertNodeAfter(sourceFile, statement, newImportDeclaration); + } + } + } + + function createOldFileImportsFromNewFile(newFileNeedExport: ReadonlySymbolSet, newFileNameWithExtension: string): ImportDeclaration | undefined { + let defaultImport: Identifier | undefined; + const imports: ImportSpecifier[] = []; + newFileNeedExport.forEach(symbol => { + if (symbol.escapedName === InternalSymbolName.Default) { + defaultImport = createIdentifier(symbolNameNoDefault(symbol)); + } + else { + imports.push(createImportSpecifier(undefined, createIdentifier(symbol.name))); + } + }); + return makeImportIfNecessary(defaultImport, imports, ensurePathIsRelative(newFileNameWithExtension)); + } + + function addExports(toMove: ReadonlyArray, needExport: ReadonlySymbolSet): ReadonlyArray { + return toMove.map(statement => { + return !hasModifier(statement, ModifierFlags.Export) && forEachTopLevelDeclaration(statement, d => needExport.has(Debug.assertDefined(d.symbol))) + ? addExport(statement as TopLevelDeclarationStatement) + : statement; + }); + } + + function deleteUnusedImports(sourceFile: SourceFile, importDecl: ImportDeclaration, changes: textChanges.ChangeTracker, isUnused: (name: Identifier) => boolean): void { + if (!importDecl.importClause) return; + const { name, namedBindings } = importDecl.importClause; + const defaultUnused = !name || isUnused(name); + const namedBindingsUnused = !namedBindings || + (namedBindings.kind === SyntaxKind.NamespaceImport ? isUnused(namedBindings.name) : namedBindings.elements.every(e => isUnused(e.name))); + if (defaultUnused && namedBindingsUnused) { + changes.deleteNode(sourceFile, importDecl); + } + else { + if (name && defaultUnused) { + changes.deleteNode(sourceFile, name); + } + if (namedBindings) { + if (namedBindingsUnused) { + changes.deleteNode(sourceFile, namedBindings); + } + else if (namedBindings.kind === SyntaxKind.NamedImports) { + for (const element of namedBindings.elements) { + if (isUnused(element.name)) changes.deleteNodeInList(sourceFile, element); + } + } + } + } + } + + function getNewFileImportsAndAddExportInOldFile( + oldFile: SourceFile, + importsToCopy: ReadonlySymbolSet, + newFileImportsFromOldFile: ReadonlySymbolSet, + changes: textChanges.ChangeTracker, + checker: TypeChecker, + ): ReadonlyArray { + const copiedOldImports = mapDefined(oldFile.statements, oldStatement => + isImportDeclaration(oldStatement) + ? filterImport(oldStatement, oldStatement.moduleSpecifier, name => importsToCopy.has(checker.getSymbolAtLocation(name))) + : undefined); + + // Also, import things used from the old file, and insert 'export' modifiers as necessary in the old file. + let oldFileDefault: Identifier | undefined; + const oldFileNamedImports: ImportSpecifier[] = []; + const markSeenTop = nodeSeenTracker(); // Needed because multiple declarations may appear in `const x = 0, y = 1;`. + newFileImportsFromOldFile.forEach(symbol => { + for (const decl of symbol.declarations) { + if (!isTopLevelDeclaration(decl) || !isIdentifier(decl.name)) continue; + + const top = getTopLevelDeclarationStatement(decl); + if (markSeenTop(top) && !hasModifier(top, ModifierFlags.Export)) { + changes.insertExportModifier(oldFile, top); + } + if (hasModifier(decl, ModifierFlags.Default)) { + oldFileDefault = decl.name; + } + else { + oldFileNamedImports.push(createImportSpecifier(undefined, decl.name)); + } + } + }); + + const oldFileImport = makeImportIfNecessary(oldFileDefault, oldFileNamedImports, `./${removeFileExtension(getBaseFileName(oldFile.fileName))}`); + return [...copiedOldImports, ...(oldFileImport ? [oldFileImport] : emptyArray)]; + } + + function makeUniqueModuleName(moduleName: string, extension: string, inDirectory: string, host: LanguageServiceHost): string { + while (true) { + const name = combinePaths(inDirectory, moduleName + extension); + if (!host.fileExists(name)) return moduleName; + moduleName += "0"; + } + } + + function getNewModuleName(movedSymbols: ReadonlySymbolSet): string { + let name: string | undefined; + movedSymbols.forEach(s => { if (name === undefined) name = symbolNameNoDefault(s); }); + return name === undefined ? "newFile" : name; + } + + interface UsageInfo { + // Symbols whose declarations are moved from the old file to the new file. + readonly movedSymbols: ReadonlySymbolSet; + + // Symbols declared in the old file that must be imported by the new file. (May not already be exported.) + readonly newFileImportsFromOldFile: ReadonlySymbolSet; + // Subset of movedSymbols that are still used elsewhere in the old file and must be imported back. + readonly oldFileImportsFromNewFile: ReadonlySymbolSet; + + readonly oldImportsNeededByNewFile: ReadonlySymbolSet; + // Subset of oldImportsNeededByNewFile that are will no longer be used in the old file. + readonly unusedImportsFromOldFile: ReadonlySymbolSet; + } + function getUsageInfo(oldFile: SourceFile, toMove: ReadonlyArray, checker: TypeChecker): UsageInfo { + const movedSymbols = new SymbolSet(); + const oldImportsNeededByNewFile = new SymbolSet(); + const newFileImportsFromOldFile = new SymbolSet(); + + for (const statement of toMove) { + forEachTopLevelDeclaration(statement, decl => { + movedSymbols.add(Debug.assertDefined(decl.symbol)); + }); + + forEachReference(statement, checker, symbol => { + if (!symbol.declarations) return; + for (const decl of symbol.declarations) { + if (isImportSpecifier(decl) || isImportClause(decl.parent)) { + oldImportsNeededByNewFile.add(symbol); + } + else if (isTopLevelDeclaration(decl) && !movedSymbols.has(symbol)) { + newFileImportsFromOldFile.add(symbol); + } + } + }); + } + + const unusedImportsFromOldFile = oldImportsNeededByNewFile.clone(); + + const oldFileImportsFromNewFile = new SymbolSet(); + for (const statement of oldFile.statements) { + if (contains(toMove, statement)) continue; + + forEachReference(statement, checker, symbol => { + if (movedSymbols.has(symbol)) oldFileImportsFromNewFile.add(symbol); + unusedImportsFromOldFile.delete(symbol); + }); + } + + return { movedSymbols, newFileImportsFromOldFile, oldFileImportsFromNewFile, oldImportsNeededByNewFile, unusedImportsFromOldFile }; + } + + // Below should all be utilities + + function filterImport(i: ImportDeclaration, moduleSpecifier: Expression, keep: (name: Identifier) => boolean): ImportDeclaration | undefined { + const clause = i.importClause; + const defaultImport = clause.name && keep(clause.name) ? clause.name : undefined; + const namedBindings = clause.namedBindings && filterNamedBindings(clause.namedBindings, keep); + return defaultImport || namedBindings ? createImportDeclaration(/*decorators*/ undefined, /*modifiers*/ undefined, createImportClause(defaultImport, namedBindings), moduleSpecifier) : undefined; + } + function filterNamedBindings(namedBindings: NamedImportBindings, keep: (name: Identifier) => boolean): NamedImportBindings | undefined { + if (namedBindings.kind === SyntaxKind.NamespaceImport) { + return keep(namedBindings.name) ? namedBindings : undefined; + } + else { + const newElements = namedBindings.elements.filter(e => keep(e.name)); + return newElements.length ? createNamedImports(newElements) : undefined; + } + } + + function forEachReference(node: Node, checker: TypeChecker, onReference: (s: Symbol) => void) { + node.forEachChild(function cb(node) { + if (isIdentifier(node) && !isDeclarationName(node)) { + const sym = checker.getSymbolAtLocation(node); + if (sym) onReference(sym); + } + else { + node.forEachChild(cb); + } + }); + } + + interface ReadonlySymbolSet { + has(symbol: Symbol): boolean; + forEach(cb: (symbol: Symbol) => void): void; + } + class SymbolSet implements ReadonlySymbolSet { + private map = createMap(); + add(symbol: Symbol): void { + this.map.set(String(getSymbolId(symbol)), symbol); + } + has(symbol: Symbol): boolean { + return this.map.has(String(getSymbolId(symbol))); + } + delete(symbol: Symbol): void { + this.map.delete(String(getSymbolId(symbol))); + } + forEach(cb: (symbol: Symbol) => void): void { + this.map.forEach(cb); + } + clone(): SymbolSet { + const clone = new SymbolSet(); + copyEntries(this.map, clone.map); + return clone; + } + } + + type NonVariableTopLevelDeclaration = FunctionDeclaration | ClassDeclaration | EnumDeclaration | TypeAliasDeclaration | InterfaceDeclaration | ModuleDeclaration; + type TopLevelDeclarationStatement = NonVariableTopLevelDeclaration | VariableStatement; + interface TopLevelVariableDeclaration extends VariableDeclaration { parent: VariableDeclarationList & { parent: VariableStatement; }; } + type TopLevelDeclaration = NonVariableTopLevelDeclaration | TopLevelVariableDeclaration; + function isTopLevelDeclaration(node: Node): node is TopLevelDeclaration { + switch (node.kind) { + case SyntaxKind.FunctionDeclaration: + case SyntaxKind.ClassDeclaration: + case SyntaxKind.ModuleDeclaration: + case SyntaxKind.EnumDeclaration: + case SyntaxKind.TypeAliasDeclaration: + case SyntaxKind.InterfaceDeclaration: + return isSourceFile(node.parent); + case SyntaxKind.VariableDeclaration: + return isSourceFile((node as VariableDeclaration).parent.parent.parent); + } + } + + function forEachTopLevelDeclaration(statement: Statement, cb: (node: TopLevelDeclaration) => T): T { + switch (statement.kind) { + case SyntaxKind.FunctionDeclaration: + case SyntaxKind.ClassDeclaration: + case SyntaxKind.ModuleDeclaration: + case SyntaxKind.EnumDeclaration: + case SyntaxKind.TypeAliasDeclaration: + case SyntaxKind.InterfaceDeclaration: + return cb(statement as FunctionDeclaration | ClassDeclaration | EnumDeclaration | ModuleDeclaration | TypeAliasDeclaration | InterfaceDeclaration); + + case SyntaxKind.VariableStatement: + return forEach((statement as VariableStatement).declarationList.declarations as ReadonlyArray, cb); + } + } + + function getTopLevelDeclarationStatement(d: TopLevelDeclaration): TopLevelDeclarationStatement { + return isVariableDeclaration(d) ? d.parent.parent : d; + } + + function addExport(d: TopLevelDeclarationStatement): TopLevelDeclarationStatement { + const modifiers = concatenate([createModifier(SyntaxKind.ExportKeyword)], d.modifiers); + switch (d.kind) { + case SyntaxKind.FunctionDeclaration: + return updateFunctionDeclaration(d, d.decorators, modifiers, d.asteriskToken, d.name, d.typeParameters, d.parameters, d.type, d.body); + case SyntaxKind.ClassDeclaration: + return updateClassDeclaration(d, d.decorators, modifiers, d.name, d.typeParameters, d.heritageClauses, d.members); + case SyntaxKind.VariableStatement: + return updateVariableStatement(d, modifiers, d.declarationList); + case SyntaxKind.ModuleDeclaration: + return updateModuleDeclaration(d, d.decorators, modifiers, d.name, d.body); + case SyntaxKind.EnumDeclaration: + return updateEnumDeclaration(d, d.decorators, modifiers, d.name, d.members); + case SyntaxKind.TypeAliasDeclaration: + return updateTypeAliasDeclaration(d, d.decorators, modifiers, d.name, d.typeParameters, d.type); + case SyntaxKind.InterfaceDeclaration: + return updateInterfaceDeclaration(d, d.decorators, modifiers, d.name, d.typeParameters, d.heritageClauses, d.members); + default: + Debug.assertNever(d); + } + } +} diff --git a/src/services/textChanges.ts b/src/services/textChanges.ts index 4f431e28e0e11..614acd617d6b4 100644 --- a/src/services/textChanges.ts +++ b/src/services/textChanges.ts @@ -211,6 +211,7 @@ namespace ts.textChanges { export class ChangeTracker { private readonly changes: Change[] = []; + private readonly newFiles: { readonly oldFile: SourceFile, readonly fileName: string, readonly statements: ReadonlyArray }[] = []; private readonly deletedNodesInLists: true[] = []; // Stores ids of nodes in lists that we already deleted. Used to avoid deleting `, ` twice in `a, b`. // Map from class id to nodes to insert at the start private readonly nodesInsertedAtClassStarts = createMap<{ sourceFile: SourceFile, cls: ClassLikeDeclaration, members: ClassElement[] }>(); @@ -520,6 +521,10 @@ namespace ts.textChanges { } } + public insertExportModifier(sourceFile: SourceFile, node: DeclarationStatement | VariableStatement): void { + this.insertText(sourceFile, node.getStart(sourceFile), "export "); + } + /** * This function should be used to insert nodes in lists when nodes don't carry separators as the part of the node range, * i.e. arguments in arguments lists, parameters in parameter lists etc. @@ -655,7 +660,15 @@ namespace ts.textChanges { */ public getChanges(validate?: ValidateNonFormattedText): FileTextChanges[] { this.finishInsertNodeAtClassStart(); - return changesToText.getTextChangesFromChanges(this.changes, this.newLineCharacter, this.formatContext, validate); + const changes = changesToText.getTextChangesFromChanges(this.changes, this.newLineCharacter, this.formatContext, validate); + for (const { oldFile, fileName, statements } of this.newFiles) { + changes.push(changesToText.newFileChanges(oldFile, fileName, statements, this.newLineCharacter)); + } + return changes; + } + + public createNewFile(oldFile: SourceFile, fileName: string, statements: ReadonlyArray) { + this.newFiles.push({ oldFile, fileName, statements }); } } @@ -678,6 +691,11 @@ namespace ts.textChanges { }); } + export function newFileChanges(oldFile: SourceFile, fileName: string, statements: ReadonlyArray, newLineCharacter: string): FileTextChanges { + const text = statements.map(s => getNonformattedText(s, oldFile, newLineCharacter).text).join(newLineCharacter); + return { fileName, textChanges: [createTextChange(createTextSpan(0, 0), text)] }; + } + function computeNewText(change: Change, sourceFile: SourceFile, newLineCharacter: string, formatContext: formatting.FormatContext, validate: ValidateNonFormattedText): string { if (change.kind === ChangeKind.Remove) { return ""; diff --git a/src/services/tsconfig.json b/src/services/tsconfig.json index 75b46a5c49bbe..2236c89300a25 100644 --- a/src/services/tsconfig.json +++ b/src/services/tsconfig.json @@ -108,6 +108,7 @@ "codefixes/useDefaultImport.ts", "refactors/extractSymbol.ts", "refactors/generateGetAccessorAndSetAccessor.ts", + "refactors/moveToNewFile.ts", "sourcemaps.ts", "services.ts", "breakpoints.ts", diff --git a/src/services/utilities.ts b/src/services/utilities.ts index cd87824e7d682..229efe845c905 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -1149,6 +1149,10 @@ namespace ts { return createTextSpanFromBounds(range.pos, range.end); } + export function createTextRangeFromSpan(span: TextSpan): TextRange { + return createTextRange(span.start, span.start + span.length); + } + export function createTextChangeFromStartLength(start: number, length: number, newText: string): TextChange { return createTextChange(createTextSpan(start, length), newText); } @@ -1221,6 +1225,36 @@ namespace ts { export function hostGetCanonicalFileName(host: LanguageServiceHost): GetCanonicalFileName { return createGetCanonicalFileName(hostUsesCaseSensitiveFileNames(host)); } + + export function makeImportIfNecessary(defaultImport: Identifier | undefined, namedImports: ReadonlyArray | undefined, moduleSpecifier: string): ImportDeclaration | undefined { + return defaultImport || namedImports && namedImports.length ? makeImport(defaultImport, namedImports, moduleSpecifier) : undefined; + } + + export function makeImport(defaultImport: Identifier | undefined, namedImports: ReadonlyArray | undefined, moduleSpecifier: string | Expression): ImportDeclaration { + return createImportDeclaration( + /*decorators*/ undefined, + /*modifiers*/ undefined, + defaultImport || namedImports + ? createImportClause(defaultImport, namedImports && namedImports.length ? createNamedImports(namedImports) : undefined) + : undefined, + typeof moduleSpecifier === "string" ? createLiteral(moduleSpecifier) : moduleSpecifier); + } + + export function symbolNameNoDefault(symbol: Symbol): string | undefined { + const escaped = symbolEscapedNameNoDefault(symbol); + return escaped === undefined ? undefined : unescapeLeadingUnderscores(escaped); + } + + export function symbolEscapedNameNoDefault(symbol: Symbol): __String | undefined { + if (symbol.escapedName !== InternalSymbolName.Default) { + return symbol.escapedName; + } + + return firstDefined(symbol.declarations, decl => { + const name = getNameOfDeclaration(decl); + return name && name.kind === SyntaxKind.Identifier ? name.escapedText : undefined; + }); + } } // Display-part writer helpers diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index 42dee74bb03e3..9d7af75f7fb25 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -349,7 +349,10 @@ declare namespace FourSlashInterface { oldPath: string; newPath: string; newFileContents: { [fileName: string]: string }; - }); + }): void; + moveToNewFile(options: { + readonly newFileContents: { readonly [fileName: string]: string }; + }): void; } class edit { backspace(count?: number): void; diff --git a/tests/cases/fourslash/moveToNewFile.ts b/tests/cases/fourslash/moveToNewFile.ts new file mode 100644 index 0000000000000..ad61803c04f51 --- /dev/null +++ b/tests/cases/fourslash/moveToNewFile.ts @@ -0,0 +1,23 @@ +/// + +// @Filename: /a.ts +////import { a, b } from "./other"; +////const p = 0; +////[|const y = p + b;|] +////y; + +verify.moveToNewFile({ + newFileContents: { + "/a.ts": +`import { y } from "./y"; + +import { a } from "./other"; +export const p = 0; +y;`, + + "/y.ts": +`import { b } from "./other"; +import { p } from "./a"; +export const y = p + b;`, + }, +}); diff --git a/tests/cases/fourslash/moveToNewFile_declarationKinds.ts b/tests/cases/fourslash/moveToNewFile_declarationKinds.ts new file mode 100644 index 0000000000000..6420a9e8ce153 --- /dev/null +++ b/tests/cases/fourslash/moveToNewFile_declarationKinds.ts @@ -0,0 +1,38 @@ +/// + +// @Filename: /a.ts +////export {}; // make this a module +////[|const x = 0; +////function f() {} +////class C {} +////enum E {} +////namespace N { export const x = 0; } +////type T = number; +////interface I {}|] +////x; f; C; E; N; +////type U = T; type V = I; + +verify.moveToNewFile({ + newFileContents: { + "/a.ts": +`import { x, f, C, E, N, T, I } from "./x"; + +export {}; // make this a module +x; f; C; E; N; +type U = T; type V = I;`, + + "/x.ts": +`export const x = 0; +export function f() { } +export class C { +} +export enum E { +} +export namespace N { + export const x = 0; +} +export type T = number; +export interface I { +}`, + }, +}); diff --git a/tests/cases/fourslash/moveToNewFile_defaultExport.ts b/tests/cases/fourslash/moveToNewFile_defaultExport.ts new file mode 100644 index 0000000000000..2a7e3e47a67c9 --- /dev/null +++ b/tests/cases/fourslash/moveToNewFile_defaultExport.ts @@ -0,0 +1,25 @@ +/// + +// @Filename: /a.ts +////[|export default function f() { }|] +////f(); + +// @Filename: /user.ts +////import f from "./a"; +////f(); + +verify.moveToNewFile({ + newFileContents: { + "/a.ts": +`import f from "./f"; + +f();`, + + "/f.ts": +`export default function f() { }`, + + "/user.ts": +`import f from "./f"; +f();`, + }, +}); diff --git a/tests/cases/fourslash/moveToNewFile_defaultImport.ts b/tests/cases/fourslash/moveToNewFile_defaultImport.ts new file mode 100644 index 0000000000000..031da380885eb --- /dev/null +++ b/tests/cases/fourslash/moveToNewFile_defaultImport.ts @@ -0,0 +1,17 @@ +/// + +// @Filename: /a.ts +////export default function f() { } +////[|const x = f();|] + +verify.moveToNewFile({ + newFileContents: { + "/a.ts": +`export default function f() { } +`, + + "/x.ts": +`import f from "./a"; +const x = f();`, + }, +}); diff --git a/tests/cases/fourslash/moveToNewFile_global.ts b/tests/cases/fourslash/moveToNewFile_global.ts new file mode 100644 index 0000000000000..04443c50cb272 --- /dev/null +++ b/tests/cases/fourslash/moveToNewFile_global.ts @@ -0,0 +1,16 @@ +/// + +// @Filename: /a.ts +////const x = y; +////[|const y = x;|] + +verify.moveToNewFile({ + newFileContents: { + "/a.ts": +`const x = y; +`, + + "/y.ts": +`const y = x;`, + }, +}); diff --git a/tests/cases/fourslash/moveToNewFile_multiple.ts b/tests/cases/fourslash/moveToNewFile_multiple.ts new file mode 100644 index 0000000000000..2a235ad9d8f70 --- /dev/null +++ b/tests/cases/fourslash/moveToNewFile_multiple.ts @@ -0,0 +1,28 @@ +/// + +// @Filename: /a.ts +////export {}; // make this a module +////const a = 0, b = 0; +////[|const x = 0; +////a; +////const y = 1; +////b;|] +////x; y; + +verify.moveToNewFile({ + newFileContents: { + "/a.ts": +`import { x, y } from "./x"; + +export {}; // make this a module +export const a = 0, b = 0; +x; y;`, + + "/x.ts": +`import { a, b } from "./a"; +export const x = 0; +a; +export const y = 1; +b;`, + }, +}); diff --git a/tests/cases/fourslash/moveToNewFile_newModuleNameUnique.ts b/tests/cases/fourslash/moveToNewFile_newModuleNameUnique.ts new file mode 100644 index 0000000000000..f05861ae5ba44 --- /dev/null +++ b/tests/cases/fourslash/moveToNewFile_newModuleNameUnique.ts @@ -0,0 +1,20 @@ +/// + +// @Filename: /a.ts +////[|export const x = 0;|] + +// @Filename: /x.ts +//// + +// @Filename: /x0.ts +//// + +verify.moveToNewFile({ + newFileContents: { + "/a.ts": +``, + + "/x00.ts": +`export const x = 0;`, + }, +}); diff --git a/tests/cases/fourslash/moveToNewFile_onlyStatements.ts b/tests/cases/fourslash/moveToNewFile_onlyStatements.ts new file mode 100644 index 0000000000000..fe019d5e9afaf --- /dev/null +++ b/tests/cases/fourslash/moveToNewFile_onlyStatements.ts @@ -0,0 +1,16 @@ +/// + +// @Filename: /a.ts +////console.log("hello"); +////[|console.log("goodbye");|] + +verify.moveToNewFile({ + newFileContents: { + "/a.ts": +`console.log("hello"); +`, + + "/newFile.ts": +`console.log("goodbye");`, + }, +}); diff --git a/tests/cases/fourslash/moveToNewFile_rangeInvalid.ts b/tests/cases/fourslash/moveToNewFile_rangeInvalid.ts new file mode 100644 index 0000000000000..cd7ec49cd04d5 --- /dev/null +++ b/tests/cases/fourslash/moveToNewFile_rangeInvalid.ts @@ -0,0 +1,13 @@ +/// + +// @Filename: /a.ts +////[|const x = 0; +////const|] y = 0; +////function f() { +//// [|function inner() {}|] +////} + +for (const range of test.ranges()) { + goTo.selectRange(range); + verify.not.refactorAvailable("Move to new file") +} diff --git a/tests/cases/fourslash/moveToNewFile_rangeSemiValid.ts b/tests/cases/fourslash/moveToNewFile_rangeSemiValid.ts new file mode 100644 index 0000000000000..0f5636f33cff8 --- /dev/null +++ b/tests/cases/fourslash/moveToNewFile_rangeSemiValid.ts @@ -0,0 +1,19 @@ +/// + +// @Filename: /a.ts +////[|const x = 0; +//// +/////** Comm|]ent */ +////const y = 0; + +verify.moveToNewFile({ + newFileContents: { + "/a.ts": +` +/** Comment */ +const y = 0;`, + + "/x.ts": +`const x = 0;`, + }, +}); diff --git a/tests/cases/fourslash/moveToNewFile_updateUses.ts b/tests/cases/fourslash/moveToNewFile_updateUses.ts new file mode 100644 index 0000000000000..ad3c649add015 --- /dev/null +++ b/tests/cases/fourslash/moveToNewFile_updateUses.ts @@ -0,0 +1,25 @@ +/// + +// @Filename: /a.ts +////export const x = 0; +////[|export const y = 0;|] + +// @Filename: /user.ts +////import { x, y } from "./a"; +//// + +verify.moveToNewFile({ + newFileContents: { + "/a.ts": +`export const x = 0; +`, + + "/y.ts": +`export const y = 0;`, + + "/user.ts": +`import { x } from "./a"; +import { y } from "./y"; +`, + }, +});