-
Notifications
You must be signed in to change notification settings - Fork 12.6k
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
Code fix for missing imports #11768
Conversation
if (sourceFile.statements) { | ||
for (const statement of sourceFile.statements) { | ||
if (statement.kind === SyntaxKind.ImportDeclaration) { | ||
imports.push(<ImportDeclaration>statement); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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]
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
?
There was a problem hiding this comment.
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) => |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
… pvb/fixmissingimports
@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; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) || []; |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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}`, |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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/")) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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)); | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?)
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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?)
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
… pvb/fixmissingimports
ab3342d
to
1f82ce7
Compare
/* @internal */ | ||
namespace ts.codefix { | ||
|
||
type ImportCodeActionKind = "CodeChange" | "InsertingIntoExistingImport" | "NewImport"; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
Implement codefix for adding missing imports.
Ping @riknoll