Skip to content

Commit

Permalink
Add refactor to convert namespace to named imports and back (#24469)
Browse files Browse the repository at this point in the history
* Add refactor to convert namespace to named imports and back

* Add tests and comments

* Code review

* Handle shorthand property assignment and re-export

* Don't use forEachFreeIdentifier

* Fix rename after "."
  • Loading branch information
Andy authored May 30, 2018
1 parent 7737167 commit 43bf039
Show file tree
Hide file tree
Showing 23 changed files with 302 additions and 57 deletions.
8 changes: 8 additions & 0 deletions src/compiler/diagnosticMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -4293,5 +4293,13 @@
"Convert '{0}' to mapped object type": {
"category": "Message",
"code": 95055
},
"Convert namespace import to named imports": {
"category": "Message",
"code": 95056
},
"Convert named imports to namespace import": {
"category": "Message",
"code": 95057
}
}
8 changes: 4 additions & 4 deletions src/compiler/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,11 @@ namespace ts {
}

/**
* Returns a value indicating whether a name is unique globally or within the current file
* Returns a value indicating whether a name is unique globally or within the current file.
* Note: This does not consider whether a name appears as a free identifier or not, so at the expression `x.y` this includes both `x` and `y`.
*/
export function isFileLevelUniqueName(currentSourceFile: SourceFile, name: string, hasGlobalName?: PrintHandlers["hasGlobalName"]): boolean {
return !(hasGlobalName && hasGlobalName(name))
&& !currentSourceFile.identifiers.has(name);
export function isFileLevelUniqueName(sourceFile: SourceFile, name: string, hasGlobalName?: PrintHandlers["hasGlobalName"]): boolean {
return !(hasGlobalName && hasGlobalName(name)) && !sourceFile.identifiers.has(name);
}

// Returns true if this node is missing from the actual source code. A 'missing' node is different
Expand Down
1 change: 1 addition & 0 deletions src/harness/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
"../services/codefixes/useDefaultImport.ts",
"../services/codefixes/fixAddModuleReferTypeMissingTypeof.ts",
"../services/codefixes/convertToMappedObjectType.ts",
"../services/refactors/convertImport.ts",
"../services/refactors/extractSymbol.ts",
"../services/refactors/generateGetAccessorAndSetAccessor.ts",
"../services/refactors/moveToNewFile.ts",
Expand Down
1 change: 1 addition & 0 deletions src/server/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"../services/codefixes/useDefaultImport.ts",
"../services/codefixes/fixAddModuleReferTypeMissingTypeof.ts",
"../services/codefixes/convertToMappedObjectType.ts",
"../services/refactors/convertImport.ts",
"../services/refactors/extractSymbol.ts",
"../services/refactors/generateGetAccessorAndSetAccessor.ts",
"../services/refactors/moveToNewFile.ts",
Expand Down
1 change: 1 addition & 0 deletions src/server/tsconfig.library.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"../services/codefixes/useDefaultImport.ts",
"../services/codefixes/fixAddModuleReferTypeMissingTypeof.ts",
"../services/codefixes/convertToMappedObjectType.ts",
"../services/refactors/convertImport.ts",
"../services/refactors/extractSymbol.ts",
"../services/refactors/generateGetAccessorAndSetAccessor.ts",
"../services/refactors/moveToNewFile.ts",
Expand Down
18 changes: 12 additions & 6 deletions src/services/codefixes/convertToEs6Module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,22 +437,28 @@ namespace ts.codefix {
type FreeIdentifiers = ReadonlyMap<ReadonlyArray<Identifier>>;
function collectFreeIdentifiers(file: SourceFile): FreeIdentifiers {
const map = createMultiMap<Identifier>();
file.forEachChild(function recur(node) {
if (isIdentifier(node) && isFreeIdentifier(node)) {
map.add(node.text, node);
}
node.forEachChild(recur);
});
forEachFreeIdentifier(file, id => map.add(id.text, id));
return map;
}

/**
* A free identifier is an identifier that can be accessed through name lookup as a local variable.
* In the expression `x.y`, `x` is a free identifier, but `y` is not.
*/
function forEachFreeIdentifier(node: Node, cb: (id: Identifier) => void): void {
if (isIdentifier(node) && isFreeIdentifier(node)) cb(node);
node.forEachChild(child => forEachFreeIdentifier(child, cb));
}

function isFreeIdentifier(node: Identifier): boolean {
const { parent } = node;
switch (parent.kind) {
case SyntaxKind.PropertyAccessExpression:
return (parent as PropertyAccessExpression).name !== node;
case SyntaxKind.BindingElement:
return (parent as BindingElement).propertyName !== node;
case SyntaxKind.ImportSpecifier:
return (parent as ImportSpecifier).propertyName !== node;
default:
return true;
}
Expand Down
23 changes: 15 additions & 8 deletions src/services/findAllReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,16 +712,23 @@ namespace ts.FindAllReferences.Core {
}

/** Used as a quick check for whether a symbol is used at all in a file (besides its definition). */
export function isSymbolReferencedInFile(definition: Identifier, checker: TypeChecker, sourceFile: SourceFile) {
export function isSymbolReferencedInFile(definition: Identifier, checker: TypeChecker, sourceFile: SourceFile): boolean {
return eachSymbolReferenceInFile(definition, checker, sourceFile, () => true) || false;
}

export function eachSymbolReferenceInFile<T>(definition: Identifier, checker: TypeChecker, sourceFile: SourceFile, cb: (token: Identifier) => T): T | undefined {
const symbol = checker.getSymbolAtLocation(definition);
if (!symbol) return true; // Be lenient with invalid code.
return getPossibleSymbolReferenceNodes(sourceFile, symbol.name).some(token => {
if (!isIdentifier(token) || token === definition || token.escapedText !== definition.escapedText) return false;
const referenceSymbol = checker.getSymbolAtLocation(token)!;
return referenceSymbol === symbol
if (!symbol) return undefined;
for (const token of getPossibleSymbolReferenceNodes(sourceFile, symbol.name)) {
if (!isIdentifier(token) || token === definition || token.escapedText !== definition.escapedText) continue;
const referenceSymbol: Symbol = checker.getSymbolAtLocation(token)!; // See GH#19955 for why the type annotation is necessary
if (referenceSymbol === symbol
|| checker.getShorthandAssignmentValueSymbol(token.parent) === symbol
|| isExportSpecifier(token.parent) && getLocalSymbolForExportSpecifier(token, referenceSymbol, token.parent, checker) === symbol;
});
|| isExportSpecifier(token.parent) && getLocalSymbolForExportSpecifier(token, referenceSymbol, token.parent, checker) === symbol) {
const res = cb(token);
if (res) return res;
}
}
}

function getPossibleSymbolReferenceNodes(sourceFile: SourceFile, symbolName: string, container: Node = sourceFile): ReadonlyArray<Node> {
Expand Down
130 changes: 130 additions & 0 deletions src/services/refactors/convertImport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/* @internal */
namespace ts.refactor.generateGetAccessorAndSetAccessor {
const refactorName = "Convert import";
const actionNameNamespaceToNamed = "Convert namespace import to named imports";
const actionNameNamedToNamespace = "Convert named imports to namespace import";
registerRefactor(refactorName, {
getAvailableActions(context): ApplicableRefactorInfo[] | undefined {
const i = getImportToConvert(context);
if (!i) return undefined;
const description = i.kind === SyntaxKind.NamespaceImport ? Diagnostics.Convert_namespace_import_to_named_imports.message : Diagnostics.Convert_named_imports_to_namespace_import.message;
const actionName = i.kind === SyntaxKind.NamespaceImport ? actionNameNamespaceToNamed : actionNameNamedToNamespace;
return [{ name: refactorName, description, actions: [{ name: actionName, description }] }];
},
getEditsForAction(context, actionName): RefactorEditInfo {
Debug.assert(actionName === actionNameNamespaceToNamed || actionName === actionNameNamedToNamespace);
const edits = textChanges.ChangeTracker.with(context, t => doChange(context.file, context.program, t, Debug.assertDefined(getImportToConvert(context))));
return { edits, renameFilename: undefined, renameLocation: undefined };
}
});

// Can convert imports of the form `import * as m from "m";` or `import d, { x, y } from "m";`.
function getImportToConvert(context: RefactorContext): NamedImportBindings | undefined {
const { file } = context;
const span = getRefactorContextSpan(context);
const token = getTokenAtPosition(file, span.start, /*includeJsDocComment*/ false);
const importDecl = getParentNodeInSpan(token, file, span);
if (!importDecl || !isImportDeclaration(importDecl)) return undefined;
const { importClause } = importDecl;
return importClause && importClause.namedBindings;
}

function doChange(sourceFile: SourceFile, program: Program, changes: textChanges.ChangeTracker, toConvert: NamedImportBindings): void {
const checker = program.getTypeChecker();
if (toConvert.kind === SyntaxKind.NamespaceImport) {
doChangeNamespaceToNamed(sourceFile, checker, changes, toConvert, getAllowSyntheticDefaultImports(program.getCompilerOptions()));
}
else {
doChangeNamedToNamespace(sourceFile, checker, changes, toConvert);
}
}

function doChangeNamespaceToNamed(sourceFile: SourceFile, checker: TypeChecker, changes: textChanges.ChangeTracker, toConvert: NamespaceImport, allowSyntheticDefaultImports: boolean): void {
let usedAsNamespaceOrDefault = false;

const nodesToReplace: PropertyAccessExpression[] = [];
const conflictingNames = createMap<true>();

FindAllReferences.Core.eachSymbolReferenceInFile(toConvert.name, checker, sourceFile, id => {
if (!isPropertyAccessExpression(id.parent)) {
usedAsNamespaceOrDefault = true;
}
else {
const parent = cast(id.parent, isPropertyAccessExpression);
const exportName = parent.name.text;
if (checker.resolveName(exportName, id, SymbolFlags.All, /*excludeGlobals*/ true)) {
conflictingNames.set(exportName, true);
}
Debug.assert(parent.expression === id);
nodesToReplace.push(parent);
}
});

// We may need to change `mod.x` to `_x` to avoid a name conflict.
const exportNameToImportName = createMap<string>();

for (const propertyAccess of nodesToReplace) {
const exportName = propertyAccess.name.text;
let importName = exportNameToImportName.get(exportName);
if (importName === undefined) {
exportNameToImportName.set(exportName, importName = conflictingNames.has(exportName) ? getUniqueName(exportName, sourceFile) : exportName);
}
changes.replaceNode(sourceFile, propertyAccess, createIdentifier(importName));
}

const importSpecifiers: ImportSpecifier[] = [];
exportNameToImportName.forEach((name, propertyName) => {
importSpecifiers.push(createImportSpecifier(name === propertyName ? undefined : createIdentifier(propertyName), createIdentifier(name)));
});

const importDecl = toConvert.parent.parent;
if (usedAsNamespaceOrDefault && !allowSyntheticDefaultImports) {
// Need to leave the namespace import alone
changes.insertNodeAfter(sourceFile, importDecl, updateImport(importDecl, /*defaultImportName*/ undefined, importSpecifiers));
}
else {
changes.replaceNode(sourceFile, importDecl, updateImport(importDecl, usedAsNamespaceOrDefault ? createIdentifier(toConvert.name.text) : undefined, importSpecifiers));
}
}

function doChangeNamedToNamespace(sourceFile: SourceFile, checker: TypeChecker, changes: textChanges.ChangeTracker, toConvert: NamedImports): void {
const importDecl = toConvert.parent.parent;
const { moduleSpecifier } = importDecl;

const preferredName = moduleSpecifier && isStringLiteral(moduleSpecifier) ? codefix.moduleSpecifierToValidIdentifier(moduleSpecifier.text, ScriptTarget.ESNext) : "module";
const namespaceNameConflicts = toConvert.elements.some(element =>
FindAllReferences.Core.eachSymbolReferenceInFile(element.name, checker, sourceFile, id =>
!!checker.resolveName(preferredName, id, SymbolFlags.All, /*excludeGlobals*/ true)) || false);
const namespaceImportName = namespaceNameConflicts ? getUniqueName(preferredName, sourceFile) : preferredName;

const neededNamedImports: ImportSpecifier[] = [];

for (const element of toConvert.elements) {
const propertyName = (element.propertyName || element.name).text;
FindAllReferences.Core.eachSymbolReferenceInFile(element.name, checker, sourceFile, id => {
const access = createPropertyAccess(createIdentifier(namespaceImportName), propertyName);
if (isShorthandPropertyAssignment(id.parent)) {
changes.replaceNode(sourceFile, id.parent, createPropertyAssignment(id.text, access));
}
else if (isExportSpecifier(id.parent) && !id.parent.propertyName) {
if (!neededNamedImports.some(n => n.name === element.name)) {
neededNamedImports.push(createImportSpecifier(element.propertyName && createIdentifier(element.propertyName.text), createIdentifier(element.name.text)));
}
}
else {
changes.replaceNode(sourceFile, id, access);
}
});
}

changes.replaceNode(sourceFile, toConvert, createNamespaceImport(createIdentifier(namespaceImportName)));
if (neededNamedImports.length) {
changes.insertNodeAfter(sourceFile, toConvert.parent.parent, updateImport(importDecl, /*defaultImportName*/ undefined, neededNamedImports));
}
}

function updateImport(old: ImportDeclaration, defaultImportName: Identifier | undefined, elements: ReadonlyArray<ImportSpecifier> | undefined): ImportDeclaration {
return createImportDeclaration(/*decorators*/ undefined, /*modifiers*/ undefined,
createImportClause(defaultImportName, elements && elements.length ? createNamedImports(elements) : undefined), old.moduleSpecifier);
}
}
21 changes: 2 additions & 19 deletions src/services/refactors/extractSymbol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ namespace ts.refactor.extractSymbol {

// Make a unique name for the extracted function
const file = scope.getSourceFile();
const functionNameText = getUniqueName(isClassLike(scope) ? "newMethod" : "newFunction", file.text);
const functionNameText = getUniqueName(isClassLike(scope) ? "newMethod" : "newFunction", file);
const isJS = isInJavaScriptFile(scope);

const functionName = createIdentifier(functionNameText);
Expand Down Expand Up @@ -1005,7 +1005,7 @@ namespace ts.refactor.extractSymbol {

// Make a unique name for the extracted variable
const file = scope.getSourceFile();
const localNameText = getUniqueName(isClassLike(scope) ? "newProperty" : "newLocal", file.text);
const localNameText = getUniqueName(isClassLike(scope) ? "newProperty" : "newLocal", file);
const isJS = isInJavaScriptFile(scope);

const variableType = isJS || !checker.isContextSensitive(node)
Expand Down Expand Up @@ -1744,23 +1744,6 @@ namespace ts.refactor.extractSymbol {
}
}

function getParentNodeInSpan(node: Node | undefined, file: SourceFile, span: TextSpan): Node | undefined {
if (!node) return undefined;

while (node.parent) {
if (isSourceFile(node.parent) || !spanContainsNode(span, node.parent, file)) {
return node;
}

node = node.parent;
}
}

function spanContainsNode(span: TextSpan, node: Node, file: SourceFile): boolean {
return textSpanContainsPosition(span, node.getStart(file)) &&
node.getEnd() <= textSpanEnd(span);
}

/**
* Computes whether or not a node represents an expression in a position where it could
* be extracted.
Expand Down
4 changes: 2 additions & 2 deletions src/services/refactors/generateGetAccessorAndSetAccessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ namespace ts.refactor.generateGetAccessorAndSetAccessor {

const name = declaration.name.text;
const startWithUnderscore = startsWithUnderscore(name);
const fieldName = createPropertyName(startWithUnderscore ? name : getUniqueName(`_${name}`, file.text), declaration.name);
const accessorName = createPropertyName(startWithUnderscore ? getUniqueName(name.substring(1), file.text) : name, declaration.name);
const fieldName = createPropertyName(startWithUnderscore ? name : getUniqueName(`_${name}`, file), declaration.name);
const accessorName = createPropertyName(startWithUnderscore ? getUniqueName(name.substring(1), file) : name, declaration.name);
return {
isStatic: hasStaticModifier(declaration),
isReadonly: hasReadonlyModifier(declaration),
Expand Down
1 change: 1 addition & 0 deletions src/services/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
"codefixes/useDefaultImport.ts",
"codefixes/fixAddModuleReferTypeMissingTypeof.ts",
"codefixes/convertToMappedObjectType.ts",
"refactors/convertImport.ts",
"refactors/extractSymbol.ts",
"refactors/generateGetAccessorAndSetAccessor.ts",
"refactors/moveToNewFile.ts",
Expand Down
32 changes: 29 additions & 3 deletions src/services/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,23 @@ namespace ts {
return forEachEntry(this.map, pred) || false;
}
}

export function getParentNodeInSpan(node: Node | undefined, file: SourceFile, span: TextSpan): Node | undefined {
if (!node) return undefined;

while (node.parent) {
if (isSourceFile(node.parent) || !spanContainsNode(span, node.parent, file)) {
return node;
}

node = node.parent;
}
}

function spanContainsNode(span: TextSpan, node: Node, file: SourceFile): boolean {
return textSpanContainsPosition(span, node.getStart(file)) &&
node.getEnd() <= textSpanEnd(span);
}
}

// Display-part writer helpers
Expand Down Expand Up @@ -1646,9 +1663,9 @@ namespace ts {
}

/* @internal */
export function getUniqueName(baseName: string, fileText: string): string {
export function getUniqueName(baseName: string, sourceFile: SourceFile): string {
let nameText = baseName;
for (let i = 1; stringContains(fileText, nameText); i++) {
for (let i = 1; !isFileLevelUniqueName(sourceFile, nameText); i++) {
nameText = `${baseName}_${i}`;
}
return nameText;
Expand All @@ -1667,7 +1684,7 @@ namespace ts {
Debug.assert(fileName === renameFilename);
for (const change of textChanges) {
const { span, newText } = change;
const index = newText.indexOf(name);
const index = indexInTextChange(newText, name);
if (index !== -1) {
lastPos = span.start + delta + index;

Expand All @@ -1685,4 +1702,13 @@ namespace ts {
Debug.assert(lastPos >= 0);
return lastPos;
}

function indexInTextChange(change: string, name: string): number {
if (startsWith(change, name)) return 0;
// Add a " " to avoid references inside words
let idx = change.indexOf(" " + name);
if (idx === -1) idx = change.indexOf("." + name);
if (idx === -1) idx = change.indexOf('"' + name);
return idx === -1 ? -1 : idx + 1;
}
}
4 changes: 2 additions & 2 deletions tests/cases/fourslash/extract-method-uniqueName.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference path='fourslash.ts' />

////// newFunction
////const newFunction = 0;
/////*start*/1 + 1/*end*/;

goTo.select('start', 'end')
Expand All @@ -9,7 +9,7 @@ edit.applyRefactor({
actionName: "function_scope_0",
actionDescription: "Extract to function in global scope",
newContent:
`// newFunction
`const newFunction = 0;
/*RENAME*/newFunction_1();
function newFunction_1() {
Expand Down
Loading

0 comments on commit 43bf039

Please sign in to comment.