Skip to content
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

Code fix for missing imports #11768

Merged
merged 18 commits into from
Nov 17, 2016
Merged

Code fix for missing imports #11768

merged 18 commits into from
Nov 17, 2016

Conversation

paulvanbrenk
Copy link
Contributor

Implement codefix for adding missing imports.

Ping @riknoll

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.

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.


// 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.

for (const file of allFiles) {
const exports = file.symbol && file.symbol.exports;
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.

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.

@zhengbli zhengbli self-assigned this Nov 2, 2016
@zhengbli
Copy link
Contributor

zhengbli commented Nov 3, 2016

@mhegazy updated, can you take another look

namespace ts {
export interface CodeFix {
errorCodes: number[];
getCodeActions(context: CodeFixContext): CodeAction[] | undefined;
getCodeActions(context: CodeFixContext, cancellationToken?: CancellationToken): CodeAction[] | undefined;
Copy link
Contributor

Choose a reason for hiding this comment

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

the context has the host which has the cancelation token anyways.

let allPotentialModules: Symbol[] = [];

const ambientModules = checker.getAmbientModules();
if (ambientModules) {
Copy link
Contributor

Choose a reason for hiding this comment

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

getAmbientModules should always return an array.

for (const moduleSymbol of allPotentialModules) {
cancellationToken.throwIfCancellationRequested();

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.

const exports = checker.getExportsOfModule(moduleSymbol) || [];
for (const exported of exports) {
if (exported.name === name) {
allActions.push(getCodeActionForImport(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 add an extra check for getMeaningFromDeclaration(exportedSymbol) & getMeaningFromLocation(token) to filter the list more.

Copy link
Contributor

Choose a reason for hiding this comment

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

We also need to handel the default export correctelly:

 if (result = moduleExports["default"]) {
    const localSymbol = getLocalSymbolForExportDefault(result);
    if (localSymbol && (result.flags & meaning) && localSymbol.name === name) {
        // found it
    }
 // otherwise it is not the right match
}               
// a.ts

Foo

// b.ts
export default class Foo {
}   

}
}

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.

* 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.

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";

//
// for case 1 and 2.2, we would return the relative file path as the module specifier;
// for case 2.1, we would just use the module name instead.
const sourceDir = getDirectoryPath(sourceFile.fileName);
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 be using paths here and not file names

// case 2.2
// 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);
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 @types/typeRoots. so use getEffictiveTypeRoots to get that

Copy link
Contributor

Choose a reason for hiding this comment

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

also i would move the check for index.d.ts to be before reading package.json

@@ -102,7 +102,8 @@ namespace ts {
isImplementationOfOverload,
getAliasedSymbol: resolveAlias,
getEmitResolver,
getExportsOfModule: getExportsOfModuleAsArray,
Copy link
Contributor

Choose a reason for hiding this comment

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

we can not rename it as this is a published API and ppl are using it already. consider adding a new method instead getExportOfModule(moduleSymbol, exportName): Symbol


function getCodeActionForExistingImport(declaration: ImportDeclaration | ImportEqualsDeclaration): CodeAction {
if (declaration.kind === SyntaxKind.ImportDeclaration) {
const namedBindings = declaration.importClause && declaration.importClause.namedBindings;
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 handel the default import case, if you already have:

import Foo, { other } from "./b";
import Foo, * as ns from "./b";

}

return {
newText: `,${oneImportPerLine ? context.newLineCharacter : ""}${name}`,
Copy link
Contributor

Choose a reason for hiding this comment

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

as for default

*
* 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"
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 rather we add a new import defalcation regardless.

Copy link
Contributor

Choose a reason for hiding this comment

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

I ended up providing two code actions to the user, and he/she can do either one. I think C# has the same behavior.

}
}

function tryGetModuleNameFromExistingUses(): string {
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 say this not worth the work here.

}

function tryGetModuleNameFromTypeRoots() {
const typesRoots = getEffectiveTypeRoots(options, context.host);
Copy link
Contributor

Choose a reason for hiding this comment

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

make sure these are normalized paths


relativeFileName = removeFileExtension(relativeFileName);

if (startsWith(relativeFileName, "@types/")) {
Copy link
Contributor

Choose a reason for hiding this comment

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

would not this already be handled by tryGetModuleNameFromTypeRoots?


const allPotentialModules = checker.getAmbientModules();
for (const otherSourceFile of allSourceFiles) {
if (otherSourceFile !== sourceFile && otherSourceFile.symbol) {
Copy link
Member

Choose a reason for hiding this comment

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

Is the presence of ".symbol" an indicator this is a module then? Is the helpful function isExternalModule not usable here? (It is more readable intuitive if so).

Copy link
Member

Choose a reason for hiding this comment

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

(Or perhaps isExternalOrCommonJSModule if we might want this for imports from JavaScript/Node files as some point too).

const localSymbol = getLocalSymbolForExportDefault(defaultExport);
if (localSymbol && localSymbol.name === name && checkSymbolHasMeaning(localSymbol, currentTokenMeaning)) {
allActions.push(getCodeActionForImport(moduleSymbol, /*isDefaultExport*/ true));
}
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.

}

// insert a new import statement in a new lines
return getCodeActionForNewImport(declaration.moduleSpecifier.getText(), declaration.getEnd());
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 why this is here. You already have an existing import for the module, why would you need to add another statement importing from the same module? Is this only if the existing statement contains a namespace import, and you want to add an import list import? (Is that valid/recommended?)

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes it is used when we want to add a new import list, while the existing one is a namespace import.

And in the latest implementation, when you have:

import * as ns from "module";
foo // foo is a member from ns

We provide two options:
1. change your code: so foo becomes ns.foo
2. add import list, so it becomes:

import * as ns from "module";
import { foo } from "module";
foo // foo is a member from ns

The user can choose from these two.

It is valid code, and as we cannot delete any user's code, I think these are the only options.

const endLine = getLineOfLocalPosition(sourceFile, importList.getEnd());
oneImportPerLine = endLine - startLine >= 2;
}
else {
Copy link
Member

Choose a reason for hiding this comment

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

Do you need this else check? Seems if the if condition was just .length > 0 the test is valid for any number of elements.

Copy link
Member

Choose a reason for hiding this comment

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

(and actually, you already return on line 115 if length was 0, so you don't even need an "if" check).

lastModuleSpecifierEnd = end;
}
}
insertPos = lastModuleSpecifierEnd > 0 ? lastModuleSpecifierEnd + 1 : sourceFile.getStart();
Copy link
Member

Choose a reason for hiding this comment

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

If there are no existing imports, it would be good to place the first prior to the first statement (i.e. after any comments), rather than the start of the file, as a file will often start with a license header.

Copy link
Contributor

Choose a reason for hiding this comment

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

Correct, and it is the current behavior, as the test importNameCodeFixNewImportAmbient2.ts shows. 

@zhengbli zhengbli changed the title Pvb/fixmissingimports Code fix for missing imports Nov 11, 2016
@zhengbli
Copy link
Contributor

ping @mhegazy @billti @paulvanbrenk @aozgaa

@zhengbli zhengbli force-pushed the pvb/fixmissingimports branch from ab3342d to 1f82ce7 Compare November 12, 2016 00:28
/* @internal */
namespace ts.codefix {

type ImportCodeActionKind = "CodeChange" | "InsertingIntoExistingImport" | "NewImport";
Copy link
Contributor

Choose a reason for hiding this comment

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

They are used to filter the code actions later. For example, inserting new item to an existing import list should take higher priority to adding a new import statement. In reality it is possible that we can get both kinds of code actions if the code has multiple existing imports

Copy link
Contributor

Choose a reason for hiding this comment

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

They are only used internally for sorting / filtering. So no need to expose them

// check exports with the same name
const exportWithIdenticalName = checker.tryGetMemberInModuleExports(name, moduleSymbol);
if (exportWithIdenticalName && checkSymbolHasMeaning(exportWithIdenticalName, currentTokenMeaning)) {
addRange(allActions, getCodeActionForImport(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 say instead of calculating the actions here, then filtering them out, is do the filtering on the symbols, then generate the code actions at the end.

this way you do not have to figure out if they are the same symbol or not.

so, consider building a map keyed off the getSymbolId(checker.getAliasedSymbol(symbol)) this way you twiddle down the list to unique symbols. if you have seen this symbol before then check the relative path length to the including file, and only keep the shorter one.

Copy link
Contributor

Choose a reason for hiding this comment

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

this way you do not need the kind as well. you can just keep them all at this point.

tryGetModuleNameAsNodeModule() ||
removeFileExtension(getRelativePath(moduleFileName, sourceDirectory));

function tryGetModuleNameFromAmbientModule(): string {
Copy link
Contributor

Choose a reason for hiding this comment

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

this does not seem to be needed. you do pass the module specifier if it is an ambient module any ways.

Copy link
Contributor

Choose a reason for hiding this comment

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

The module specifier is passed only when there is already an existing import declaration in this file, otherwise this is still needed.

// in this case we should provie 2 actions:
// 1. change "member3" to "ns.member3"
// 2. add "member3" to the second import statement's import list
// and it is up to the user to decide which one fits best.
Copy link
Contributor

Choose a reason for hiding this comment

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

mm... i am not sure why this would be better than just picking the "best" one.

Copy link
Contributor

Choose a reason for hiding this comment

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

when would the user need to choose one over the other?

Copy link
Contributor

@zhengbli zhengbli Nov 16, 2016

Choose a reason for hiding this comment

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

Say the user already has a import * as ns in his code, which means he intends to use it somewhere, then the first choice might be good. And as you previously suggested, the second option might solve more errors because it addresses all identifiers with the same name in this file. It all comes down to personal preferences. However, I feel there are really no objective criteria regarding which one is "always" better, just provide two choices might be better than enforcing our own understanding.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants