Skip to content

Code fix for missing imports #11768

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Nov 17, 2016
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/compiler/diagnosticMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -3112,5 +3112,17 @@
"Implement inherited abstract class": {
"category": "Message",
"code": 90007
},
"Import {0} from {1}": {
"category": "Message",
"code": 90008
},
"Change {0} to {1}": {
"category": "Message",
"code": 90009
},
"Add {0} to existing import declaration from {1}": {
"category": "Message",
"code": 90010
}
}
3 changes: 2 additions & 1 deletion src/services/codefixes/codeFixProvider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* @internal */
/* @internal */
namespace ts {
export interface CodeFix {
errorCodes: number[];
Expand All @@ -11,6 +11,7 @@ namespace ts {
span: TextSpan;
program: Program;
newLineCharacter: string;
host: LanguageServiceHost;
}

export namespace codefix {
Expand Down
1 change: 1 addition & 0 deletions src/services/codefixes/fixes.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
///<reference path='superFixes.ts' />
///<reference path='importFixes.ts' />
291 changes: 291 additions & 0 deletions src/services/codefixes/importFixes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
/* @internal */
namespace ts.codefix {
const nodeModulesDir = "node_modules";

registerCodeFix({
errorCodes: [Diagnostics.Cannot_find_name_0.code],
getCodeActions: (context: CodeFixContext) => {
const sourceFile = context.sourceFile;
const checker = context.program.getTypeChecker();
const allFiles = context.program.getSourceFiles();
const useCaseSensitiveFileNames = context.host.useCaseSensitiveFileNames ? context.host.useCaseSensitiveFileNames() : false;

const token = getTokenAtPosition(sourceFile, context.span.start);
const name = token.getText();
const allActions: CodeAction[] = [];

// Get existing ImportDeclarations from the source file
const imports: ImportDeclaration[] = [];
if (sourceFile.statements) {
for (const statement of sourceFile.statements) {
if (statement.kind === SyntaxKind.ImportDeclaration) {
imports.push(<ImportDeclaration>statement);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use soruceFile.resolvedModules instead of rewalking the tree.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The closest thing I can find is the sourceFIle.Imports, however I don't know how to get the import declarations from the module names. Maybe I can call getSymbolAtLocation? Or do you know there are better ways.

}
}
}

// Check if a matching symbol is exported by any ambient modules that has been declared
const ambientModules = checker.getAmbientModules();
for (const moduleSymbol of ambientModules) {
const exports = checker.getExportsOfModule(moduleSymbol) || [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would recommend either exposing the getExportsOfModule that returns a symbolTable or adding a new getExportOfModule(name): Symbol | undefined instead of the loop.

for (const exported of exports) {
if (exported.name === name) {
allActions.push(getCodeActionForImport(removeQuotes(moduleSymbol.getName())));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what the above few lines do. Can you add some explanatory comments? In what scenario can we detect that you need to import a default export? It has no name to match on, as the local name for the 'default' export is provided by the import statement.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I think I see the scenario in the *ImportDefault0.ts test-case, but is that a real scenario? How often is a default export a named function, and how often is that the identifier you just happened to try and refer to it as?)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have data, but a quick search in the DefinitelyTyped repo did give me some library files with named export default, for example the material-ui library. And in the latest implementation I return multiple code fixes to the user, so that he/she can choose which one he/she likes the best.

}
}

// Check if a matching symbol is exported by any files known to the compiler
for (const file of allFiles) {
const exports = file.symbol && file.symbol.exports;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check cancellation toke for every file.

if (exports) {
for (const exported in exports) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why? just check exports[name]

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually this is not accurate, you want to use checker.ts::getExportsOfModule here to make sure export * from "module" are reflected here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean instead of file.symbol && file.symbol.exports, use getExportsOfModule(file.symbol)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes.

if (exported === name) {
let moduleSpecifier: string;
const sourceDir = getDirectoryPath(sourceFile.fileName);
if (file.fileName.indexOf(nodeModulesDir) !== -1) {
moduleSpecifier = convertPathToModuleSpecifier(file.fileName);
}
else {
// Try and convert the file path into one relative to the source file

const pathName = getRelativePathToDirectoryOrUrl(
sourceDir,
file.fileName,
sourceDir,
createGetCanonicalFileName(useCaseSensitiveFileNames),
/* isAbsolutePathAnUrl */ false
);

// Make sure we got back a path that can be a valid module specifier
const isRootedOrRelative = isExternalModuleNameRelative(pathName) || isRootedDiskPath(pathName);
moduleSpecifier = removeFileExtension(isRootedOrRelative ? pathName : combinePaths(".", pathName));
}

allActions.push(getCodeActionForImport(moduleSpecifier, (a, b) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could get the same symbol from multiple imports, we need to filter them and only include the "best" for a symbol. where best is:

  • a module you already imported
  • a module in your source
    • a module that is "closer" in path to the current file
  • a module in a declaration file

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should split this into phases, first phase is to find all [exported symbol, module] that match the name we are looking for, then the next phase is to generate the action for each symbol.

compareModuleSpecifiers(a, b, sourceDir, useCaseSensitiveFileNames)
));
}
}
}
}

return allActions;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth investigating the order of the result. possibly by the shortest path.


function convertPathToModuleSpecifier(modulePath: string): string {
const i = modulePath.lastIndexOf(nodeModulesDir);

// This is not a module from the node_module folder,
// so just retun the path
if (i === -1) {
return modulePath;
}

// If this is a node module, check to see if the given file is the main export of the module or not. If so,
// it can be referenced by just the module name.
const moduleDir = getDirectoryPath(modulePath);
let nodePackage: any;
try {
nodePackage = JSON.parse(context.host.readFile(combinePaths(moduleDir, "package.json")));
}
catch (e) { }

// If no main export is explicitly defined, check for the default (index.js)
const mainExport = (nodePackage && nodePackage.main) || "index.js";
const mainExportPath = normalizePath(isRootedDiskPath(mainExport) ? mainExport : combinePaths(moduleDir, mainExport));

const moduleSpecifier = removeFileExtension(modulePath.substring(i + nodeModulesDir.length + 1));

if (compareModuleSpecifiers(modulePath, mainExportPath, getDirectoryPath(sourceFile.fileName), useCaseSensitiveFileNames) === Comparison.EqualTo) {
return getDirectoryPath(moduleSpecifier);
}
else {
return moduleSpecifier;
}
}

function getCodeActionForImport(moduleName: string, isEqual: (a: string, b: string) => Comparison = compareStrings): CodeAction {
// Check to see if there are already imports being made from this source in the current file
const existing = forEach(imports, (importDeclaration) => {
if (isEqual(removeQuotes(importDeclaration.moduleSpecifier.getText()), moduleName) === Comparison.EqualTo) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the fileName is not guaranteed to be the same as the module name. e.g. path mapping. use sourcefile.resolvedModules, build a reverse lookup map and use it here.

return importDeclaration;
}
});

if (existing) {
return getCodeActionForExistingImport(existing);
}
else {
return getCodeActionForNewImport();
}

function getCodeActionForExistingImport(declaration: ImportDeclaration): CodeAction {
const moduleSpecifier = declaration.moduleSpecifier.getText();

// We have to handle all of the different import declaration forms
if (declaration.importClause) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to handle the case where the name of the exported symbol is "default" but the local is a different name.

options:

import {default as Foo} from "mod";

or

import default from "mod";

if (declaration.importClause.namedBindings) {
const namedBindings = declaration.importClause.namedBindings;
if (namedBindings.kind === SyntaxKind.NamespaceImport) {
/**
* Cases:
* import * as ns from "mod"
* import d, * as ns from "mod"
*
* Because there is no import list, we alter the reference to include the
* namespace instead of altering the import declaration. For example, "foo" would
* become "ns.foo"
*/
const ns = (<NamespaceImport>namedBindings).name.getText();
return createCodeAction(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would just add an import as in getCodeActionForNewImport

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should also handle import equals declarations the same way.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After trying it I find it quite annoying if we insert a new line for every single imports. I tried webstorm, and they do consolidate imports from the same file together.

Diagnostics.Change_0_to_1,
[name, `${ns}.${name}`],
`${ns}.`,
{ start: token.getStart(), length: 0 },
sourceFile.fileName
);
}
else if (namedBindings.kind === SyntaxKind.NamedImports) {
/**
* Cases:
* import { a, b as x } from "mod"
* import d, { a, b as x } from "mod"
*
* Because there is already an import list, just insert the identifier into it
*/
const textChange = getTextChangeForImportList(<NamedImports>namedBindings);
return createCodeAction(
Diagnostics.Add_0_to_existing_import_declaration_from_1,
[name, moduleSpecifier],
textChange.newText,
textChange.span,
sourceFile.fileName
);
}
}
else if (declaration.importClause.name) {
/**
* Case: import d from "mod"
*
* Add a list of imports after the default import
*/
return createCodeAction(
Diagnostics.Add_0_to_existing_import_declaration_from_1,
[name, moduleSpecifier],
`, { ${name} }`,
{ start: declaration.importClause.name.getEnd(), length: 0 },
sourceFile.fileName
);
}

function getTextChangeForImportList(importList: NamedImports): TextChange {
if (importList.elements.length === 0) {
const start = importList.getStart();
return {
newText: `{ ${name} }`,
span: { start, length: importList.getEnd() - start }
};
}

// Insert after the last element
const insertPoint = importList.elements[importList.elements.length - 1].getEnd();

// If the import list has one import per line, preserve that. Otherwise, insert on same line as last element
let oneImportPerLine: boolean;

if (importList.elements.length === 1) {
/**
* If there is only one symbol being imported, still check to see if it's set up for multi-line imports like this:
* import {
* foo
* } from "./module";
*/
const startLine = getLineOfLocalPosition(sourceFile, importList.getStart());
const endLine = getLineOfLocalPosition(sourceFile, importList.getEnd());

oneImportPerLine = endLine - startLine >= 2;
}
else {
const startLine = getLineOfLocalPosition(sourceFile, importList.elements[0].getStart());
const endLine = getLineOfLocalPosition(sourceFile, insertPoint);

oneImportPerLine = endLine - startLine >= importList.elements.length - 1;
}

return {
newText: oneImportPerLine ? `, ${context.newLineCharacter}${name}` : `,${name}`,
span: { start: insertPoint, length: 0 }
};
}

}

return createCodeAction(
Diagnostics.Add_0_to_existing_import_declaration_from_1,
[name, moduleSpecifier],
`{ ${name} } from `,
{ start: declaration.moduleSpecifier.getStart(), length: 0 },
sourceFile.fileName
);
}

function getCodeActionForNewImport(): CodeAction {
// Try to insert after any existing imports
let lastDeclaration: ImportDeclaration;
let lastEnd: number;
for (const declaration of imports) {
const end = declaration.getEnd();
if (!lastDeclaration || end > lastEnd) {
lastDeclaration = declaration;
lastEnd = end;
}
}

let newText = `import { ${name} } from "${moduleName}";`;
newText = lastDeclaration ? context.newLineCharacter + newText : newText + context.newLineCharacter;

return createCodeAction(
Diagnostics.Import_0_from_1,
[name, `"${moduleName}"`],
newText,
{
start: lastDeclaration ? lastEnd : sourceFile.getStart(),
length: 0
},
sourceFile.fileName
);
}
}

function removeQuotes(name: string): string {
if ((startsWith(name, "\"") && endsWith(name, "\"")) || (startsWith(name, "'") && endsWith(name, "'"))) {
return name.substr(1, name.length - 2);
}
else {
return name;
}
}

function compareModuleSpecifiers(a: string, b: string, basePath: string, useCaseSensitiveFileNames: boolean): Comparison {
// Paths to modules can be relative or absolute and may optionally include the file
// extension of the module
a = removeFileExtension(a);
b = removeFileExtension(b);
return comparePaths(a, b, basePath, !useCaseSensitiveFileNames);
}

function createCodeAction(description: DiagnosticMessage, diagnosticArgs: string[], newText: string, span: TextSpan, fileName: string): CodeAction {
return {
description: formatMessage.apply(undefined, [undefined, description].concat(<any[]>diagnosticArgs)),
changes: [{
fileName,
textChanges: [{
newText,
span
}]
}]
};
}
}
});
}
5 changes: 3 additions & 2 deletions src/services/services.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/// <reference path="..\compiler\program.ts"/>
/// <reference path="..\compiler\program.ts"/>
/// <reference path="..\compiler\commandLineParser.ts"/>

/// <reference path='types.ts' />
Expand Down Expand Up @@ -1676,7 +1676,8 @@ namespace ts {
sourceFile: sourceFile,
span: span,
program: program,
newLineCharacter: newLineChar
newLineCharacter: newLineChar,
host: host
};

const fixes = codefix.getFixes(context);
Expand Down
10 changes: 10 additions & 0 deletions tests/cases/fourslash/importNameCodeFixExistingImport0.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/// <reference path="fourslash.ts" />

//// import [|{ v1 }|] from "./module";
//// f1/*0*/();

// @Filename: module.ts
//// export function f1() {}
//// export var v1 = 5;

verify.codeFixAtPosition(`{ v1, f1 }`);
11 changes: 11 additions & 0 deletions tests/cases/fourslash/importNameCodeFixExistingImport1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// <reference path="fourslash.ts" />

//// import d, [|{ v1 }|] from "./module";
//// f1/*0*/();

// @Filename: module.ts
//// export function f1() {}
//// export var v1 = 5;
//// export default var d1 = 6;

verify.codeFixAtPosition(`{ v1, f1 }`);
21 changes: 21 additions & 0 deletions tests/cases/fourslash/importNameCodeFixExistingImport10.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/// <reference path="fourslash.ts" />

//// import [|{
//// v1,
//// v2
//// }|] from "./module";
//// f1/*0*/();

// @Filename: module.ts
//// export function f1() {}
//// export var v1 = 5;
//// export var v2 = 5;
//// export var v3 = 5;

verify.codeFixAtPosition(
`{
v1,
v2,
f1
}`
);
Loading