diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index 7d08fd5caf..6bc5f3f0c6 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -4418,5 +4418,13 @@ "Remove braces from arrow function": { "category": "Message", "code": 95060 + }, + "Convert default export to named export": { + "category": "Message", + "code": 95061 + }, + "Convert named export to default export": { + "category": "Message", + "code": 95062 } } diff --git a/src/compiler/utilities.ts b/src/compiler/utilities.ts index 2c62e44fad..c9278b55ed 100644 --- a/src/compiler/utilities.ts +++ b/src/compiler/utilities.ts @@ -5844,7 +5844,7 @@ namespace ts { // Keywords /* @internal */ - export function isModifierKind(token: SyntaxKind): boolean { + export function isModifierKind(token: SyntaxKind): token is Modifier["kind"] { switch (token) { case SyntaxKind.AbstractKeyword: case SyntaxKind.AsyncKeyword: diff --git a/src/harness/fourslash.ts b/src/harness/fourslash.ts index 177d98c37b..1881fa0e61 100644 --- a/src/harness/fourslash.ts +++ b/src/harness/fourslash.ts @@ -451,6 +451,12 @@ namespace FourSlash { this.selectionEnd = end.position; } + public selectAllInFile(fileName: string) { + this.openFile(fileName); + this.goToPosition(0); + this.selectionEnd = this.activeFile.content.length; + } + public selectRange(range: Range): void { this.goToRangeStart(range); this.selectionEnd = range.end; @@ -3090,9 +3096,22 @@ Actual: ${stringify(fullActual)}`); this.applyEdits(edit.fileName, edit.textChanges, /*isFormattingEdit*/ false); } - const { renamePosition, newContent } = parseNewContent(); + let renameFilename: string | undefined; + let renamePosition: number | undefined; - this.verifyCurrentFileContent(newContent); + const newFileContents = typeof newContentWithRenameMarker === "string" ? { [this.activeFile.fileName]: newContentWithRenameMarker } : newContentWithRenameMarker; + for (const fileName in newFileContents) { + const { renamePosition: rp, newContent } = TestState.parseNewContent(newFileContents[fileName]); + if (renamePosition === undefined) { + renameFilename = fileName; + renamePosition = rp; + } + else { + ts.Debug.assert(rp === undefined); + } + this.verifyFileContent(fileName, newContent); + + } if (renamePosition === undefined) { if (editInfo.renameLocation !== undefined) { @@ -3100,22 +3119,21 @@ Actual: ${stringify(fullActual)}`); } } else { - // TODO: test editInfo.renameFilename value - assert.isDefined(editInfo.renameFilename); + this.assertObjectsEqual(editInfo.renameFilename, renameFilename); if (renamePosition !== editInfo.renameLocation) { this.raiseError(`Expected rename position of ${renamePosition}, but got ${editInfo.renameLocation}`); } } + } - function parseNewContent(): { renamePosition: number | undefined, newContent: string } { - const renamePosition = newContentWithRenameMarker.indexOf("/*RENAME*/"); - if (renamePosition === -1) { - return { renamePosition: undefined, newContent: newContentWithRenameMarker }; - } - else { - const newContent = newContentWithRenameMarker.slice(0, renamePosition) + newContentWithRenameMarker.slice(renamePosition + "/*RENAME*/".length); - return { renamePosition, newContent }; - } + private static parseNewContent(newContentWithRenameMarker: string): { readonly renamePosition: number | undefined, readonly newContent: string } { + const renamePosition = newContentWithRenameMarker.indexOf("/*RENAME*/"); + if (renamePosition === -1) { + return { renamePosition: undefined, newContent: newContentWithRenameMarker }; + } + else { + const newContent = newContentWithRenameMarker.slice(0, renamePosition) + newContentWithRenameMarker.slice(renamePosition + "/*RENAME*/".length); + return { renamePosition, newContent }; } } @@ -3966,6 +3984,10 @@ namespace FourSlashInterface { this.state.select(startMarker, endMarker); } + public selectAllInFile(fileName: string) { + this.state.selectAllInFile(fileName); + } + public selectRange(range: FourSlash.Range): void { this.state.selectRange(range); } @@ -4736,7 +4758,7 @@ namespace FourSlashInterface { refactorName: string; actionName: string; actionDescription: string; - newContent: string; + newContent: NewFileContent; } export type ExpectedCompletionEntry = string | { @@ -4798,9 +4820,11 @@ namespace FourSlashInterface { filesToSearch?: ReadonlyArray; } + export type NewFileContent = string | { readonly [filename: string]: string }; + export interface NewContentOptions { // Exactly one of these should be defined. - newFileContent?: string | { readonly [filename: string]: string }; + newFileContent?: NewFileContent; newRangeContent?: string; } diff --git a/src/services/documentHighlights.ts b/src/services/documentHighlights.ts index 458b12c2ad..6976cd2ea2 100644 --- a/src/services/documentHighlights.ts +++ b/src/services/documentHighlights.ts @@ -187,15 +187,8 @@ namespace ts.DocumentHighlights { }); } - function getModifierOccurrences(modifier: SyntaxKind, declaration: Node): Node[] { - const modifierFlag = modifierToFlag(modifier); - return mapDefined(getNodesToSearchForModifier(declaration, modifierFlag), node => { - if (getModifierFlags(node) & modifierFlag) { - const mod = find(node.modifiers!, m => m.kind === modifier); - Debug.assert(!!mod); - return mod; - } - }); + function getModifierOccurrences(modifier: Modifier["kind"], declaration: Node): Node[] { + return mapDefined(getNodesToSearchForModifier(declaration, modifierToFlag(modifier)), node => findModifier(node, modifier)); } function getNodesToSearchForModifier(declaration: Node, modifierFlag: ModifierFlags): ReadonlyArray | undefined { diff --git a/src/services/findAllReferences.ts b/src/services/findAllReferences.ts index 0fbcd5cd6e..043856e4ab 100644 --- a/src/services/findAllReferences.ts +++ b/src/services/findAllReferences.ts @@ -590,6 +590,30 @@ namespace ts.FindAllReferences.Core { } } + export function eachExportReference( + sourceFiles: ReadonlyArray, + checker: TypeChecker, + cancellationToken: CancellationToken | undefined, + exportSymbol: Symbol, + exportingModuleSymbol: Symbol, + exportName: string, + isDefaultExport: boolean, + cb: (ref: Identifier) => void, + ): void { + const importTracker = createImportTracker(sourceFiles, arrayToSet(sourceFiles, f => f.fileName), checker, cancellationToken); + const { importSearches, indirectUsers } = importTracker(exportSymbol, { exportKind: isDefaultExport ? ExportKind.Default : ExportKind.Named, exportingModuleSymbol }, /*isForRename*/ false); + for (const [importLocation] of importSearches) { + cb(importLocation); + } + for (const indirectUser of indirectUsers) { + for (const node of getPossibleSymbolReferenceNodes(indirectUser, isDefaultExport ? "default" : exportName)) { + if (isIdentifier(node) && checker.getSymbolAtLocation(node) === exportSymbol) { + cb(node); + } + } + } + } + function shouldAddSingleReference(singleRef: Identifier | StringLiteral, state: State): boolean { if (!hasMatchingMeaning(singleRef, state)) return false; if (!state.options.isForRename) return true; diff --git a/src/services/importTracker.ts b/src/services/importTracker.ts index 7861f28402..77b3c02c80 100644 --- a/src/services/importTracker.ts +++ b/src/services/importTracker.ts @@ -12,7 +12,7 @@ namespace ts.FindAllReferences { export type ImportTracker = (exportSymbol: Symbol, exportInfo: ExportInfo, isForRename: boolean) => ImportsResult; /** Creates the imports map and returns an ImportTracker that uses it. Call this lazily to avoid calling `getDirectImportsMap` unnecessarily. */ - export function createImportTracker(sourceFiles: ReadonlyArray, sourceFilesSet: ReadonlyMap, checker: TypeChecker, cancellationToken: CancellationToken): ImportTracker { + export function createImportTracker(sourceFiles: ReadonlyArray, sourceFilesSet: ReadonlyMap, checker: TypeChecker, cancellationToken: CancellationToken | undefined): ImportTracker { const allDirectImports = getDirectImportsMap(sourceFiles, checker, cancellationToken); return (exportSymbol, exportInfo, isForRename) => { const { directImports, indirectUsers } = getImportersForExport(sourceFiles, sourceFilesSet, allDirectImports, exportInfo, checker, cancellationToken); @@ -43,7 +43,7 @@ namespace ts.FindAllReferences { allDirectImports: Map, { exportingModuleSymbol, exportKind }: ExportInfo, checker: TypeChecker, - cancellationToken: CancellationToken + cancellationToken: CancellationToken | undefined, ): { directImports: Importer[], indirectUsers: ReadonlyArray } { const markSeenDirectImport = nodeSeenTracker(); const markSeenIndirectUser = nodeSeenTracker(); @@ -80,7 +80,7 @@ namespace ts.FindAllReferences { continue; } - cancellationToken.throwIfCancellationRequested(); + if (cancellationToken) cancellationToken.throwIfCancellationRequested(); switch (direct.kind) { case SyntaxKind.CallExpression: @@ -363,11 +363,11 @@ namespace ts.FindAllReferences { } /** Returns a map from a module symbol Id to all import statements that directly reference the module. */ - function getDirectImportsMap(sourceFiles: ReadonlyArray, checker: TypeChecker, cancellationToken: CancellationToken): Map { + function getDirectImportsMap(sourceFiles: ReadonlyArray, checker: TypeChecker, cancellationToken: CancellationToken | undefined): Map { const map = createMap(); for (const sourceFile of sourceFiles) { - cancellationToken.throwIfCancellationRequested(); + if (cancellationToken) cancellationToken.throwIfCancellationRequested(); forEachImport(sourceFile, (importDecl, moduleSpecifier) => { const moduleSymbol = checker.getSymbolAtLocation(moduleSpecifier); if (moduleSymbol) { diff --git a/src/services/refactors/convertExport.ts b/src/services/refactors/convertExport.ts new file mode 100644 index 0000000000..6c7710c0fe --- /dev/null +++ b/src/services/refactors/convertExport.ts @@ -0,0 +1,212 @@ +/* @internal */ +namespace ts.refactor { + const refactorName = "Convert export"; + const actionNameDefaultToNamed = "Convert default export to named export"; + const actionNameNamedToDefault = "Convert named export to default export"; + registerRefactor(refactorName, { + getAvailableActions(context): ApplicableRefactorInfo[] | undefined { + const info = getInfo(context); + if (!info) return undefined; + const description = info.wasDefault ? Diagnostics.Convert_default_export_to_named_export.message : Diagnostics.Convert_named_export_to_default_export.message; + const actionName = info.wasDefault ? actionNameDefaultToNamed : actionNameNamedToDefault; + return [{ name: refactorName, description, actions: [{ name: actionName, description }] }]; + }, + getEditsForAction(context, actionName): RefactorEditInfo { + Debug.assert(actionName === actionNameDefaultToNamed || actionName === actionNameNamedToDefault); + const edits = textChanges.ChangeTracker.with(context, t => doChange(context.file, context.program, Debug.assertDefined(getInfo(context)), t, context.cancellationToken)); + return { edits, renameFilename: undefined, renameLocation: undefined }; + }, + }); + + // If a VariableStatement, will have exactly one VariableDeclaration, with an Identifier for a name. + type ExportToConvert = FunctionDeclaration | ClassDeclaration | InterfaceDeclaration | EnumDeclaration | NamespaceDeclaration | TypeAliasDeclaration | VariableStatement; + interface Info { + readonly exportNode: ExportToConvert; + readonly exportName: Identifier; // This is exportNode.name except for VariableStatement_s. + readonly wasDefault: boolean; + readonly exportingModuleSymbol: Symbol; + } + + function getInfo(context: RefactorContext): Info | undefined { + const { file } = context; + const span = getRefactorContextSpan(context); + const token = getTokenAtPosition(file, span.start, /*includeJsDocComment*/ false); + const exportNode = getParentNodeInSpan(token, file, span); + if (!exportNode || (!isSourceFile(exportNode.parent) && !(isModuleBlock(exportNode.parent) && isAmbientModule(exportNode.parent.parent)))) { + return undefined; + } + + const exportingModuleSymbol = isSourceFile(exportNode.parent) ? exportNode.parent.symbol : exportNode.parent.parent.symbol; + + const flags = getModifierFlags(exportNode); + const wasDefault = !!(flags & ModifierFlags.Default); + // If source file already has a default export, don't offer refactor. + if (!(flags & ModifierFlags.Export) || !wasDefault && exportingModuleSymbol.exports!.has(InternalSymbolName.Default)) { + return undefined; + } + + switch (exportNode.kind) { + case SyntaxKind.FunctionDeclaration: + case SyntaxKind.ClassDeclaration: + case SyntaxKind.InterfaceDeclaration: + case SyntaxKind.EnumDeclaration: + case SyntaxKind.TypeAliasDeclaration: + case SyntaxKind.ModuleDeclaration: { + const node = exportNode as FunctionDeclaration | ClassDeclaration | InterfaceDeclaration | EnumDeclaration | TypeAliasDeclaration | NamespaceDeclaration; + return node.name && isIdentifier(node.name) ? { exportNode: node, exportName: node.name, wasDefault, exportingModuleSymbol } : undefined; + } + case SyntaxKind.VariableStatement: { + const vs = exportNode as VariableStatement; + // Must be `export const x = something;`. + if (!(vs.declarationList.flags & NodeFlags.Const) || vs.declarationList.declarations.length !== 1) { + return undefined; + } + const decl = first(vs.declarationList.declarations); + if (!decl.initializer) return undefined; + Debug.assert(!wasDefault); + return isIdentifier(decl.name) ? { exportNode: vs, exportName: decl.name, wasDefault, exportingModuleSymbol } : undefined; + } + default: + return undefined; + } + } + + function doChange(exportingSourceFile: SourceFile, program: Program, info: Info, changes: textChanges.ChangeTracker, cancellationToken: CancellationToken | undefined): void { + changeExport(exportingSourceFile, info, changes, program.getTypeChecker()); + changeImports(program, info, changes, cancellationToken); + } + + function changeExport(exportingSourceFile: SourceFile, { wasDefault, exportNode, exportName }: Info, changes: textChanges.ChangeTracker, checker: TypeChecker): void { + if (wasDefault) { + changes.deleteNode(exportingSourceFile, Debug.assertDefined(findModifier(exportNode, SyntaxKind.DefaultKeyword))); + } + else { + const exportKeyword = Debug.assertDefined(findModifier(exportNode, SyntaxKind.ExportKeyword)); + switch (exportNode.kind) { + case SyntaxKind.FunctionDeclaration: + case SyntaxKind.ClassDeclaration: + case SyntaxKind.InterfaceDeclaration: + changes.insertNodeAfter(exportingSourceFile, exportKeyword, createToken(SyntaxKind.DefaultKeyword)); + break; + case SyntaxKind.VariableStatement: + // If 'x' isn't used in this file, `export const x = 0;` --> `export default 0;` + if (!FindAllReferences.Core.isSymbolReferencedInFile(exportName, checker, exportingSourceFile)) { + // We checked in `getInfo` that an initializer exists. + changes.replaceNode(exportingSourceFile, exportNode, createExportDefault(Debug.assertDefined(first(exportNode.declarationList.declarations).initializer))); + break; + } + // falls through + case SyntaxKind.EnumDeclaration: + case SyntaxKind.TypeAliasDeclaration: + case SyntaxKind.ModuleDeclaration: + // `export type T = number;` -> `type T = number; export default T;` + changes.deleteModifier(exportingSourceFile, exportKeyword); + changes.insertNodeAfter(exportingSourceFile, exportNode, createExportDefault(createIdentifier(exportName.text))); + break; + default: + Debug.assertNever(exportNode); + } + } + } + + function changeImports(program: Program, { wasDefault, exportName, exportingModuleSymbol }: Info, changes: textChanges.ChangeTracker, cancellationToken: CancellationToken | undefined): void { + const checker = program.getTypeChecker(); + const exportSymbol = Debug.assertDefined(checker.getSymbolAtLocation(exportName)); + FindAllReferences.Core.eachExportReference(program.getSourceFiles(), checker, cancellationToken, exportSymbol, exportingModuleSymbol, exportName.text, wasDefault, ref => { + const importingSourceFile = ref.getSourceFile(); + if (wasDefault) { + changeDefaultToNamedImport(importingSourceFile, ref, changes, exportName.text); + } + else { + changeNamedToDefaultImport(importingSourceFile, ref, changes); + } + }); + } + + function changeDefaultToNamedImport(importingSourceFile: SourceFile, ref: Identifier, changes: textChanges.ChangeTracker, exportName: string): void { + const { parent } = ref; + switch (parent.kind) { + case SyntaxKind.PropertyAccessExpression: + // `a.default` --> `a.foo` + changes.replaceNode(importingSourceFile, ref, createIdentifier(exportName)); + break; + case SyntaxKind.ImportSpecifier: + case SyntaxKind.ExportSpecifier: { + const spec = parent as ImportSpecifier | ExportSpecifier; + // `default as foo` --> `foo`, `default as bar` --> `foo as bar` + changes.replaceNode(importingSourceFile, spec, makeImportSpecifier(exportName, spec.name.text)); + break; + } + case SyntaxKind.ImportClause: { + const clause = parent as ImportClause; + Debug.assert(clause.name === ref); + const spec = makeImportSpecifier(exportName, ref.text); + const { namedBindings } = clause; + if (!namedBindings) { + // `import foo from "./a";` --> `import { foo } from "./a";` + changes.replaceNode(importingSourceFile, ref, createNamedImports([spec])); + } + else if (namedBindings.kind === SyntaxKind.NamespaceImport) { + // `import foo, * as a from "./a";` --> `import * as a from ".a/"; import { foo } from "./a";` + changes.deleteNode(importingSourceFile, ref); + const quotePreference = isStringLiteral(clause.parent.moduleSpecifier) ? quotePreferenceFromString(clause.parent.moduleSpecifier, importingSourceFile) : QuotePreference.Double; + const newImport = makeImport(/*default*/ undefined, [makeImportSpecifier(exportName, ref.text)], clause.parent.moduleSpecifier, quotePreference); + changes.insertNodeAfter(importingSourceFile, clause.parent, newImport); + } + else { + // `import foo, { bar } from "./a"` --> `import { bar, foo } from "./a";` + changes.deleteNode(importingSourceFile, ref); + changes.insertNodeAtEndOfList(importingSourceFile, namedBindings.elements, spec); + } + break; + } + default: + Debug.failBadSyntaxKind(parent); + } + } + + function changeNamedToDefaultImport(importingSourceFile: SourceFile, ref: Identifier, changes: textChanges.ChangeTracker): void { + const { parent } = ref; + switch (parent.kind) { + case SyntaxKind.PropertyAccessExpression: + // `a.foo` --> `a.default` + changes.replaceNode(importingSourceFile, ref, createIdentifier("default")); + break; + case SyntaxKind.ImportSpecifier: + case SyntaxKind.ExportSpecifier: { + const spec = parent as ImportSpecifier | ExportSpecifier; + if (spec.kind === SyntaxKind.ImportSpecifier) { + // `import { foo } from "./a";` --> `import foo from "./a";` + // `import { foo as bar } from "./a";` --> `import bar from "./a";` + const defaultImport = createIdentifier(spec.name.text); + if (spec.parent.elements.length === 1) { + changes.replaceNode(importingSourceFile, spec.parent, defaultImport); + } + else { + changes.deleteNodeInList(importingSourceFile, spec); + changes.insertNodeBefore(importingSourceFile, spec.parent, defaultImport); + } + } + else { + // `export { foo } from "./a";` --> `export { default as foo } from "./a";` + // `export { foo as bar } from "./a";` --> `export { default as bar } from "./a";` + // `export { foo as default } from "./a";` --> `export { default } from "./a";` + // (Because `export foo from "./a";` isn't valid syntax.) + changes.replaceNode(importingSourceFile, spec, makeExportSpecifier("default", spec.name.text)); + } + break; + } + default: + Debug.failBadSyntaxKind(parent); + } + + } + + function makeImportSpecifier(propertyName: string, name: string): ImportSpecifier { + return createImportSpecifier(propertyName === name ? undefined : createIdentifier(propertyName), createIdentifier(name)); + } + + function makeExportSpecifier(propertyName: string, name: string): ExportSpecifier { + return createExportSpecifier(propertyName === name ? undefined : createIdentifier(propertyName), createIdentifier(name)); + } +} diff --git a/src/services/refactors/convertImport.ts b/src/services/refactors/convertImport.ts index bdd3e7f4a9..cfdd9baffd 100644 --- a/src/services/refactors/convertImport.ts +++ b/src/services/refactors/convertImport.ts @@ -1,5 +1,5 @@ /* @internal */ -namespace ts.refactor.generateGetAccessorAndSetAccessor { +namespace ts.refactor { const refactorName = "Convert import"; const actionNameNamespaceToNamed = "Convert namespace import to named imports"; const actionNameNamedToNamespace = "Convert named imports to namespace import"; diff --git a/src/services/textChanges.ts b/src/services/textChanges.ts index a5eed1418a..3001f5cf44 100644 --- a/src/services/textChanges.ts +++ b/src/services/textChanges.ts @@ -241,6 +241,10 @@ namespace ts.textChanges { return this; } + public deleteModifier(sourceFile: SourceFile, modifier: Modifier): void { + this.deleteRange(sourceFile, { pos: modifier.getStart(sourceFile), end: skipTrivia(sourceFile.text, modifier.end, /*stopAfterLineBreak*/ true) }); + } + public deleteNodeRange(sourceFile: SourceFile, startNode: Node, endNode: Node, options: ConfigurableStartEnd = {}) { const startPosition = getAdjustedStartPosition(sourceFile, startNode, options, Position.FullStart); const endPosition = getAdjustedEndPosition(sourceFile, endNode, options); @@ -397,6 +401,9 @@ namespace ts.textChanges { else if (isParameter(before)) { return {}; } + else if (isStringLiteral(before) && isImportDeclaration(before.parent) || isNamedImports(before)) { + return { suffix: ", " }; + } return Debug.failBadSyntaxKind(before); // We haven't handled this kind of node yet -- add it } @@ -465,6 +472,10 @@ namespace ts.textChanges { this.insertNodeAt(sourceFile, endPosition, newNode, this.getInsertNodeAfterOptions(sourceFile, after)); } + public insertNodeAtEndOfList(sourceFile: SourceFile, list: NodeArray, newNode: Node): void { + this.insertNodeAt(sourceFile, list.end, newNode, { prefix: ", " }); + } + public insertNodesAfter(sourceFile: SourceFile, after: Node, newNodes: ReadonlyArray): void { const endPosition = this.insertNodeAfterWorker(sourceFile, after, first(newNodes)); this.insertNodesAt(sourceFile, endPosition, newNodes, this.getInsertNodeAfterOptions(sourceFile, after)); @@ -502,6 +513,9 @@ namespace ts.textChanges { case SyntaxKind.PropertyAssignment: return { suffix: "," + this.newLineCharacter }; + case SyntaxKind.ExportKeyword: + return { prefix: " " }; + case SyntaxKind.Parameter: return {}; diff --git a/src/services/tsconfig.json b/src/services/tsconfig.json index 9318bbe446..6e1495d78a 100644 --- a/src/services/tsconfig.json +++ b/src/services/tsconfig.json @@ -71,6 +71,7 @@ "codefixes/useDefaultImport.ts", "codefixes/fixAddModuleReferTypeMissingTypeof.ts", "codefixes/convertToMappedObjectType.ts", + "refactors/convertExport.ts", "refactors/convertImport.ts", "refactors/extractSymbol.ts", "refactors/generateGetAccessorAndSetAccessor.ts", diff --git a/src/services/utilities.ts b/src/services/utilities.ts index 481d20e91b..eb710b28dc 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -1281,13 +1281,17 @@ namespace ts { export const enum QuotePreference { Single, Double } + export function quotePreferenceFromString(str: StringLiteral, sourceFile: SourceFile): QuotePreference { + return isStringDoubleQuoted(str, sourceFile) ? QuotePreference.Double : QuotePreference.Single; + } + export function getQuotePreference(sourceFile: SourceFile, preferences: UserPreferences): QuotePreference { if (preferences.quotePreference) { return preferences.quotePreference === "single" ? QuotePreference.Single : QuotePreference.Double; } else { - const firstModuleSpecifier = firstOrUndefined(sourceFile.imports); - return !!firstModuleSpecifier && !isStringDoubleQuoted(firstModuleSpecifier, sourceFile) ? QuotePreference.Single : QuotePreference.Double; + const firstModuleSpecifier = sourceFile.imports && find(sourceFile.imports, isStringLiteral); + return firstModuleSpecifier ? quotePreferenceFromString(firstModuleSpecifier, sourceFile) : QuotePreference.Double; } } @@ -1384,6 +1388,10 @@ namespace ts { node.getEnd() <= textSpanEnd(span); } + export function findModifier(node: Node, kind: Modifier["kind"]): Modifier | undefined { + return node.modifiers && find(node.modifiers, m => m.kind === kind); + } + /* @internal */ export function insertImport(changes: textChanges.ChangeTracker, sourceFile: SourceFile, importDecl: Statement): void { const lastImportDeclaration = findLast(sourceFile.statements, isAnyImportSyntax); diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 38fd4697c2..f5a41005ed 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -5913,6 +5913,8 @@ declare namespace ts { Add_or_remove_braces_in_an_arrow_function: DiagnosticMessage; Add_braces_to_arrow_function: DiagnosticMessage; Remove_braces_from_arrow_function: DiagnosticMessage; + Convert_default_export_to_named_export: DiagnosticMessage; + Convert_named_export_to_default_export: DiagnosticMessage; }; } declare namespace ts { @@ -6947,7 +6949,7 @@ declare namespace ts { function isTemplateMiddleOrTemplateTail(node: Node): node is TemplateMiddle | TemplateTail; function isStringTextContainingNode(node: Node): node is StringLiteral | TemplateLiteralToken; function isGeneratedIdentifier(node: Node): node is GeneratedIdentifier; - function isModifierKind(token: SyntaxKind): boolean; + function isModifierKind(token: SyntaxKind): token is Modifier["kind"]; function isParameterPropertyModifier(kind: SyntaxKind): boolean; function isClassMemberModifier(idToken: SyntaxKind): boolean; function isModifier(node: Node): node is Modifier; @@ -10781,6 +10783,7 @@ declare namespace ts { Single = 0, Double = 1 } + function quotePreferenceFromString(str: StringLiteral, sourceFile: SourceFile): QuotePreference; function getQuotePreference(sourceFile: SourceFile, preferences: UserPreferences): QuotePreference; function symbolNameNoDefault(symbol: Symbol): string | undefined; function symbolEscapedNameNoDefault(symbol: Symbol): __String | undefined; @@ -10805,6 +10808,7 @@ declare namespace ts { some(pred: (node: Node) => boolean): boolean; } function getParentNodeInSpan(node: Node | undefined, file: SourceFile, span: TextSpan): Node | undefined; + function findModifier(node: Node, kind: Modifier["kind"]): Modifier | undefined; function insertImport(changes: textChanges.ChangeTracker, sourceFile: SourceFile, importDecl: Statement): void; } declare namespace ts { @@ -10982,7 +10986,7 @@ declare namespace ts.FindAllReferences { } type ImportTracker = (exportSymbol: Symbol, exportInfo: ExportInfo, isForRename: boolean) => ImportsResult; /** Creates the imports map and returns an ImportTracker that uses it. Call this lazily to avoid calling `getDirectImportsMap` unnecessarily. */ - function createImportTracker(sourceFiles: ReadonlyArray, sourceFilesSet: ReadonlyMap, checker: TypeChecker, cancellationToken: CancellationToken): ImportTracker; + function createImportTracker(sourceFiles: ReadonlyArray, sourceFilesSet: ReadonlyMap, checker: TypeChecker, cancellationToken: CancellationToken | undefined): ImportTracker; /** Info about an exported symbol to perform recursive search on. */ interface ExportInfo { exportingModuleSymbol: Symbol; @@ -11085,6 +11089,7 @@ declare namespace ts.FindAllReferences { declare namespace ts.FindAllReferences.Core { /** Core find-all-references algorithm. Handles special cases before delegating to `getReferencedSymbolsForSymbol`. */ function getReferencedSymbolsForNode(position: number, node: Node, program: Program, sourceFiles: ReadonlyArray, cancellationToken: CancellationToken, options?: Options, sourceFilesSet?: ReadonlyMap): SymbolAndEntries[] | undefined; + function eachExportReference(sourceFiles: ReadonlyArray, checker: TypeChecker, cancellationToken: CancellationToken | undefined, exportSymbol: Symbol, exportingModuleSymbol: Symbol, exportName: string, isDefaultExport: boolean, cb: (ref: Identifier) => void): void; /** Used as a quick check for whether a symbol is used at all in a file (besides its definition). */ function isSymbolReferencedInFile(definition: Identifier, checker: TypeChecker, sourceFile: SourceFile): boolean; function eachSymbolReferenceInFile(definition: Identifier, checker: TypeChecker, sourceFile: SourceFile, cb: (token: Identifier) => T): T | undefined; @@ -11470,6 +11475,7 @@ declare namespace ts.textChanges { deleteRange(sourceFile: SourceFile, range: TextRange): this; /** Warning: This deletes comments too. See `copyComments` in `convertFunctionToEs6Class`. */ deleteNode(sourceFile: SourceFile, node: Node, options?: ConfigurableStartEnd): this; + deleteModifier(sourceFile: SourceFile, modifier: Modifier): void; deleteNodeRange(sourceFile: SourceFile, startNode: Node, endNode: Node, options?: ConfigurableStartEnd): this; deleteNodeRangeExcludingEnd(sourceFile: SourceFile, startNode: Node, afterEndNode: Node | undefined, options?: ConfigurableStartEnd): void; deleteNodeInList(sourceFile: SourceFile, node: Node): this; @@ -11501,6 +11507,7 @@ declare namespace ts.textChanges { private getInsertNodeAtClassStartPrefixSuffix; insertNodeAfterComma(sourceFile: SourceFile, after: Node, newNode: Node): void; insertNodeAfter(sourceFile: SourceFile, after: Node, newNode: Node): void; + insertNodeAtEndOfList(sourceFile: SourceFile, list: NodeArray, newNode: Node): void; insertNodesAfter(sourceFile: SourceFile, after: Node, newNodes: ReadonlyArray): void; private insertNodeAfterWorker; private getInsertNodeAfterOptions; @@ -11658,7 +11665,9 @@ declare namespace ts.codefix { } declare namespace ts.codefix { } -declare namespace ts.refactor.generateGetAccessorAndSetAccessor { +declare namespace ts.refactor { +} +declare namespace ts.refactor { } declare namespace ts.refactor.extractSymbol { /** diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index 89e1948ce7..0753ded25c 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -139,6 +139,7 @@ declare namespace FourSlashInterface { file(name: string, content?: string, scriptKindName?: string): any; select(startMarker: string, endMarker: string): void; selectRange(range: Range): void; + selectAllInFile(fileName: string): void; } class verifyNegatable { private negative; @@ -178,7 +179,7 @@ declare namespace FourSlashInterface { isInCommentAtPosition(onlyMultiLineDiverges?: boolean): void; codeFix(options: { description: string, - newFileContent?: string | { readonly [fileName: string]: string }, + newFileContent?: NewFileContent, newRangeContent?: string, errorCode?: number, index?: number, @@ -336,7 +337,7 @@ declare namespace FourSlashInterface { getEditsForFileRename(options: { oldPath: string; newPath: string; - newFileContents: { [fileName: string]: string }; + newFileContents: { readonly [fileName: string]: string }; }): void; moveToNewFile(options: { readonly newFileContents: { readonly [fileName: string]: string }; @@ -357,7 +358,7 @@ declare namespace FourSlashInterface { enableFormatting(): void; disableFormatting(): void; - applyRefactor(options: { refactorName: string, actionName: string, actionDescription: string, newContent: string }): void; + applyRefactor(options: { refactorName: string, actionName: string, actionDescription: string, newContent: NewFileContent }): void; } class debug { printCurrentParameterHelp(): void; @@ -573,6 +574,7 @@ declare namespace FourSlashInterface { } type ArrayOrSingle = T | ReadonlyArray; + type NewFileContent = string | { readonly [fileName: string]: string }; } declare function verifyOperationIsCancelled(f: any): void; declare var test: FourSlashInterface.test_; diff --git a/tests/cases/fourslash/refactorConvertExport_ambientModule.ts b/tests/cases/fourslash/refactorConvertExport_ambientModule.ts new file mode 100644 index 0000000000..a0e39c7a25 --- /dev/null +++ b/tests/cases/fourslash/refactorConvertExport_ambientModule.ts @@ -0,0 +1,25 @@ +/// + +// @Filename: /foo.ts +////declare module "foo" { +//// /*a*/export default function foo(): void;/*b*/ +////} + +// @Filename: /b.ts +////import foo from "foo"; + +goTo.select("a", "b"); +edit.applyRefactor({ + refactorName: "Convert export", + actionName: "Convert default export to named export", + actionDescription: "Convert default export to named export", + newContent: { + "/foo.ts": +`declare module "foo" { + export function foo(): void; +}`, + + "/b.ts": +`import { foo } from "foo";`, +}, +}); diff --git a/tests/cases/fourslash/refactorConvertExport_defaultToNamed.ts b/tests/cases/fourslash/refactorConvertExport_defaultToNamed.ts new file mode 100644 index 0000000000..52d259e841 --- /dev/null +++ b/tests/cases/fourslash/refactorConvertExport_defaultToNamed.ts @@ -0,0 +1,41 @@ +/// + +// @Filename: /a.ts +/////*a*/export default function f() {}/*b*/ + +// @Filename: /b.ts +////import f from "./a"; +////import { default as f } from "./a"; +////import { default as g } from "./a"; +////import f, * as a from "./a"; // TODO: GH#24875 +//// +////export { default } from "./a"; +////export { default as f } from "./a"; +////export { default as i } from "./a"; +//// +////import * as a from "./a"; +////a.default(); + +goTo.select("a", "b"); +edit.applyRefactor({ + refactorName: "Convert export", + actionName: "Convert default export to named export", + actionDescription: "Convert default export to named export", + newContent: { + "/a.ts": +`export function f() {}`, + + "/b.ts": +`import { f } from "./a"; +import { f } from "./a"; +import { f as g } from "./a"; +import f, * as a from "./a"; // TODO: GH#24875 + +export { f as default } from "./a"; +export { f } from "./a"; +export { f as i } from "./a"; + +import * as a from "./a"; +a.f();`, +}, +}); diff --git a/tests/cases/fourslash/refactorConvertExport_exportNodeKinds.ts b/tests/cases/fourslash/refactorConvertExport_exportNodeKinds.ts new file mode 100644 index 0000000000..4e73f8e7c9 --- /dev/null +++ b/tests/cases/fourslash/refactorConvertExport_exportNodeKinds.ts @@ -0,0 +1,79 @@ +/// + +// @Filename: /fn.ts +////export function f() {} + +// @Filename: /cls.ts +////export class C {} + +// @Filename: /interface.ts +////export interface I {} + +// @Filename: /enum.ts +////export const enum E {} + +// @Filename: /namespace.ts +////export namespace N {} + +// @Filename: /type.ts +////export type T = number; + +// @Filename: /var_unused.ts +////export const x = 0; + +// @Filename: /var_unused_noInitializer.ts +////export const x; + +// @Filename: /var_used.ts +////export const x = 0; +////x; + +const tests: { [fileName: string]: string | undefined } = { + fn: `export default function f() {}`, + + cls: `export default class C {}`, + + interface: `export default interface I {}`, + + enum: +`const enum E {} +export default E; +`, + + namespace: +`namespace N {} + +export default N; +`, + + type: +`type T = number; +export default T; +`, + + var_unused: `export default 0;`, + + var_unused_noInitializer: undefined, + + var_used: +`const x = 0; +export default x; +x;`, +}; + +for (const name in tests) { + const newContent = tests[name]; + const fileName = `/${name}.ts`; + goTo.selectAllInFile(fileName); + if (newContent === undefined) { + verify.refactorsAvailable([]); + } + else { + edit.applyRefactor({ + refactorName: "Convert export", + actionName: "Convert named export to default export", + actionDescription: "Convert named export to default export", + newContent: { [fileName]: newContent }, + }); + } +} diff --git a/tests/cases/fourslash/refactorConvertExport_namedToDefault.ts b/tests/cases/fourslash/refactorConvertExport_namedToDefault.ts new file mode 100644 index 0000000000..4e179d3c79 --- /dev/null +++ b/tests/cases/fourslash/refactorConvertExport_namedToDefault.ts @@ -0,0 +1,39 @@ +/// + +// @Filename: /a.ts +/////*a*/export function f() {}/*b*/ + +// @Filename: /b.ts +////import { f } from "./a"; +////import { f as g } from "./a"; +////import { f, other } from "./a"; +//// +////export { f } from "./a"; +////export { f as i } from "./a"; +////export { f as default } from "./a"; +//// +////import * as a from "./a"; +////a.f(); + +goTo.select("a", "b"); +edit.applyRefactor({ + refactorName: "Convert export", + actionName: "Convert named export to default export", + actionDescription: "Convert named export to default export", + newContent: { + "/a.ts": +`export default function f() {}`, + + "/b.ts": +`import f from "./a"; +import g from "./a"; +import f, { other } from "./a"; + +export { default as f } from "./a"; +export { default as i } from "./a"; +export { default } from "./a"; + +import * as a from "./a"; +a.default();`, +}, +}); diff --git a/tests/cases/fourslash/refactorConvertExport_namedToDefault_alreadyHasDefault.ts b/tests/cases/fourslash/refactorConvertExport_namedToDefault_alreadyHasDefault.ts new file mode 100644 index 0000000000..1e9faf5bd5 --- /dev/null +++ b/tests/cases/fourslash/refactorConvertExport_namedToDefault_alreadyHasDefault.ts @@ -0,0 +1,8 @@ +/// + +// @Filename: /a.ts +/////*a*/export function f() {}/*b*/ +////export default function g() {} + +goTo.select("a", "b"); +verify.refactorsAvailable([]);