diff --git a/src/compiler/checker.ts b/src/compiler/checker.ts index ce5f5a4f1c522..abec3bbb8ca7b 100644 --- a/src/compiler/checker.ts +++ b/src/compiler/checker.ts @@ -6233,11 +6233,13 @@ namespace ts { function symbolsToArray(symbols: SymbolTable): Symbol[] { const result: Symbol[] = []; - symbols.forEach((symbol, id) => { - if (!isReservedMemberName(id)) { - result.push(symbol); - } - }); + if (symbols) { + symbols.forEach((symbol, id) => { + if (!isReservedMemberName(id)) { + result.push(symbol); + } + }); + } return result; } diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index 596e080430ca9..41fbc977da62b 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -3751,6 +3751,43 @@ namespace ts.projectSystem { }); }); + describe("import in completion list", () => { + it("should include exported members of all source files", () => { + const file1: FileOrFolder = { + path: "/a/b/file1.ts", + content: ` + export function Test1() { } + export function Test2() { } + ` + }; + const file2: FileOrFolder = { + path: "/a/b/file2.ts", + content: ` + import { Test2 } from "./file1"; + + t` + }; + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: "{}" + }; + + const host = createServerHost([file1, file2, configFile]); + const service = createProjectService(host); + service.openClientFile(file2.path); + + const completions1 = service.configuredProjects[0].getLanguageService().getCompletionsAtPosition(file2.path, file2.content.length); + const test1Entry = find(completions1.entries, e => e.name === "Test1"); + const test2Entry = find(completions1.entries, e => e.name === "Test2"); + + assert.isDefined(test1Entry, "should contain 'Test1'"); + assert.isDefined(test2Entry, "should contain 'Test2'"); + + assert.isTrue(test1Entry.hasAction, "should set the 'hasAction' property to true for Test1"); + assert.isUndefined(test2Entry.hasAction, "should not set the 'hasAction' property for Test2"); + }); + }); + describe("import helpers", () => { it("should not crash in tsserver", () => { const f1 = { @@ -4090,6 +4127,70 @@ namespace ts.projectSystem { }); }); + describe("completion entry with code actions", () => { + it("should work for symbols from non-imported modules", () => { + const moduleFile = { + path: "/a/b/moduleFile.ts", + content: `export const guitar = 10;` + }; + const file1 = { + path: "/a/b/file2.ts", + content: `var x:` + }; + const globalFile = { + path: "/a/b/globalFile.ts", + content: `interface Jazz { }` + }; + const ambientModuleFile = { + path: "/a/b/ambientModuleFile.ts", + content: + `declare module "windyAndWarm" { + export const chetAtkins = "great"; + }` + }; + const defaultModuleFile = { + path: "/a/b/defaultModuleFile.ts", + content: + `export default function egyptianElla() { };` + }; + const configFile = { + path: "/a/b/tsconfig.json", + content: "{}" + }; + + const host = createServerHost([moduleFile, file1, globalFile, ambientModuleFile, defaultModuleFile, configFile]); + const session = createSession(host); + const projectService = session.getProjectService(); + projectService.openClientFile(file1.path); + + checkEntryDetail(1, "guitar", /*hasAction*/ true, `import { guitar } from "./moduleFile";\n\n`); + checkEntryDetail(1, "chetAtkins", /*hasAction*/ true, `import { chetAtkins } from "windyAndWarm";\n\n`); + checkEntryDetail(1, "egyptianElla", /*hasAction*/ true, `import egyptianElla from "./defaultModuleFile";\n\n`); + checkEntryDetail(7, "Jazz", /*hasAction*/ false); + + function checkEntryDetail(offset: number, entryName: string, hasAction: boolean, insertString?: string) { + const request = makeSessionRequest( + CommandNames.CompletionDetails, + { entryNames: [entryName], file: file1.path, line: 1, offset, projectFileName: configFile.path }); + const response = session.executeCommand(request).response as protocol.CompletionEntryDetails[]; + assert.equal(response.length, 1); + + const entryDetails = response[0]; + if (!hasAction) { + assert.isUndefined(entryDetails.codeActions); + } + else { + const action = entryDetails.codeActions[0]; + assert.equal(action.changes[0].fileName, file1.path); + assert.deepEqual(action.changes[0], { + fileName: file1.path, + textChanges: [{ start: { line: 1, offset: 1 }, end: { line: 1, offset: 1 }, newText: insertString }] + }); + } + } + }); + }); + describe("maxNodeModuleJsDepth for inferred projects", () => { it("should be set to 2 if the project has js root files", () => { const file1: FileOrFolder = { diff --git a/src/server/client.ts b/src/server/client.ts index a7e0615dce866..a234060038911 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -198,7 +198,9 @@ namespace ts.server { const request = this.processRequest(CommandNames.CompletionDetails, args); const response = this.processResponse(request); Debug.assert(response.body.length === 1, "Unexpected length of completion details response body."); - return response.body[0]; + + const convertedCodeActions = map(response.body[0].codeActions, codeAction => this.convertCodeActions(codeAction, fileName)); + return { ...response.body[0], codeActions: convertedCodeActions }; } getCompletionEntrySymbol(_fileName: string, _position: number, _entryName: string): Symbol { diff --git a/src/server/protocol.ts b/src/server/protocol.ts index 0b7405c7b6986..e0b5015025b1c 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -1642,6 +1642,11 @@ namespace ts.server.protocol { * this span should be used instead of the default one. */ replacementSpan?: TextSpan; + /** + * Indicating if commiting this completion entry will require additional code action to be + * made to avoid errors. The code action is normally adding an additional import statement. + */ + hasAction?: true; } /** @@ -1674,6 +1679,11 @@ namespace ts.server.protocol { * JSDoc tags for the symbol. */ tags: JSDocTagInfo[]; + + /** + * The associated code actions for this entry + */ + codeActions?: CodeAction[]; } export interface CompletionsResponse extends Response { diff --git a/src/server/session.ts b/src/server/session.ts index 4122408cfa6bb..0d5fb36c376de 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1178,9 +1178,15 @@ namespace ts.server { if (simplifiedResult) { return mapDefined(completions && completions.entries, entry => { if (completions.isMemberCompletion || (entry.name.toLowerCase().indexOf(prefix.toLowerCase()) === 0)) { - const { name, kind, kindModifiers, sortText, replacementSpan } = entry; + const { name, kind, kindModifiers, sortText, replacementSpan, hasAction } = entry; const convertedSpan = replacementSpan ? this.decorateSpan(replacementSpan, scriptInfo) : undefined; - return { name, kind, kindModifiers, sortText, replacementSpan: convertedSpan }; + + const newEntry: protocol.CompletionEntry = { name, kind, kindModifiers, sortText, replacementSpan: convertedSpan }; + // avoid serialization when hasAction = false + if (hasAction) { + newEntry.hasAction = true; + } + return newEntry; } }).sort((a, b) => compareStrings(a.name, b.name)); } @@ -1193,9 +1199,18 @@ namespace ts.server { const { file, project } = this.getFileAndProject(args); const scriptInfo = project.getScriptInfoForNormalizedPath(file); const position = this.getPosition(args, scriptInfo); + const formattingOptions = project.projectService.getFormatCodeOptions(file); - return mapDefined(args.entryNames, entryName => - project.getLanguageService().getCompletionEntryDetails(file, position, entryName)); + return mapDefined(args.entryNames, entryName => { + const details = project.getLanguageService().getCompletionEntryDetails(file, position, entryName, formattingOptions); + if (details) { + const mappedCodeActions = map(details.codeActions, action => this.mapCodeAction(action, scriptInfo)); + return { ...details, codeActions: mappedCodeActions }; + } + else { + return undefined; + } + }); } private getCompileOnSaveAffectedFileList(args: protocol.FileRequestArgs): ReadonlyArray { diff --git a/src/services/codefixes/importFixes.ts b/src/services/codefixes/importFixes.ts index d0b52184031ef..4b2fd109df554 100644 --- a/src/services/codefixes/importFixes.ts +++ b/src/services/codefixes/importFixes.ts @@ -11,11 +11,27 @@ namespace ts.codefix { }); type ImportCodeActionKind = "CodeChange" | "InsertingIntoExistingImport" | "NewImport"; + type ImportDeclarationMap = (ImportDeclaration | ImportEqualsDeclaration)[][]; + interface ImportCodeAction extends CodeAction { kind: ImportCodeActionKind; moduleSpecifier?: string; } + export interface ImportCodeFixContext { + host: LanguageServiceHost; + symbolName: string; + newLineCharacter: string; + rulesProvider: formatting.RulesProvider; + sourceFile: SourceFile; + checker: TypeChecker; + compilerOptions: CompilerOptions; + getCanonicalFileName: (fileName: string) => string; + // this is a module id -> module import declaration map + cachedImportDeclarations?: ImportDeclarationMap; + symbolToken?: Node; + } + enum ModuleSpecifierComparison { Better, Equal, @@ -122,77 +138,51 @@ namespace ts.codefix { } } - function getImportCodeActions(context: CodeFixContext): ImportCodeAction[] { - const sourceFile = context.sourceFile; - const checker = context.program.getTypeChecker(); - const allSourceFiles = context.program.getSourceFiles(); - const useCaseSensitiveFileNames = context.host.useCaseSensitiveFileNames ? context.host.useCaseSensitiveFileNames() : false; + function createCodeAction( + description: DiagnosticMessage, + diagnosticArgs: string[], + changes: FileTextChanges[], + kind: ImportCodeActionKind, + moduleSpecifier?: string): ImportCodeAction { + return { + description: formatMessage.apply(undefined, [undefined, description].concat(diagnosticArgs)), + changes, + kind, + moduleSpecifier + }; + } - const token = getTokenAtPosition(sourceFile, context.span.start, /*includeJsDocComment*/ false); - const name = token.getText(); - const symbolIdActionMap = new ImportCodeActionMap(); + function convertToImportCodeFixContext(context: CodeFixContext) { + const useCaseSensitiveFileNames = context.host.useCaseSensitiveFileNames ? context.host.useCaseSensitiveFileNames() : false; + const checker = context.program.getTypeChecker(); + const token = getTokenAtPosition(context.sourceFile, context.span.start, /*includeJsDocComment*/ false); + return { + ...context, + checker, + compilerOptions: context.program.getCompilerOptions(), + cachedImportDeclarations: [], + getCanonicalFileName: createGetCanonicalFileName(useCaseSensitiveFileNames), + symbolName: token.getText(), + symbolToken: token + }; + } - // this is a module id -> module import declaration map - const cachedImportDeclarations: (ImportDeclaration | ImportEqualsDeclaration)[][] = []; + export function getCodeActionForImport(moduleSymbol: Symbol, context: ImportCodeFixContext, symbolName: string, isDefault?: boolean, isNamespaceImport?: boolean): ImportCodeAction[] { let lastImportDeclaration: Node; + const { symbolName: name, sourceFile, getCanonicalFileName, newLineCharacter, host, checker, symbolToken, compilerOptions } = context; + const cachedImportDeclarations = context.cachedImportDeclarations || []; - const currentTokenMeaning = getMeaningFromLocation(token); - if (context.errorCode === Diagnostics._0_refers_to_a_UMD_global_but_the_current_file_is_a_module_Consider_adding_an_import_instead.code) { - const umdSymbol = checker.getSymbolAtLocation(token); - let symbol: ts.Symbol; - let symbolName: string; - if (umdSymbol.flags & ts.SymbolFlags.Alias) { - symbol = checker.getAliasedSymbol(umdSymbol); - symbolName = name; - } - else if (isJsxOpeningLikeElement(token.parent) && token.parent.tagName === token) { - // The error wasn't for the symbolAtLocation, it was for the JSX tag itself, which needs access to e.g. `React`. - symbol = checker.getAliasedSymbol(checker.resolveName(checker.getJsxNamespace(), token.parent.tagName, SymbolFlags.Value)); - symbolName = symbol.name; - } - else { - Debug.fail("Either the symbol or the JSX namespace should be a UMD global if we got here"); - } - - return getCodeActionForImport(symbol, symbolName, /*isDefault*/ false, /*isNamespaceImport*/ true); - } - - const candidateModules = checker.getAmbientModules(); - for (const otherSourceFile of allSourceFiles) { - if (otherSourceFile !== sourceFile && isExternalOrCommonJsModule(otherSourceFile)) { - candidateModules.push(otherSourceFile.symbol); - } + const existingDeclarations = getImportDeclarations(); + if (existingDeclarations.length > 0) { + // With an existing import statement, there are more than one actions the user can do. + return getCodeActionsForExistingImport(existingDeclarations); } - - for (const moduleSymbol of candidateModules) { - context.cancellationToken.throwIfCancellationRequested(); - - // check the default export - const defaultExport = checker.tryGetMemberInModuleExports("default", moduleSymbol); - if (defaultExport) { - const localSymbol = getLocalSymbolForExportDefault(defaultExport); - if (localSymbol && localSymbol.escapedName === name && checkSymbolHasMeaning(localSymbol, currentTokenMeaning)) { - // check if this symbol is already used - const symbolId = getUniqueSymbolId(localSymbol); - symbolIdActionMap.addActions(symbolId, getCodeActionForImport(moduleSymbol, name, /*isNamespaceImport*/ true)); - } - } - - // "default" is a keyword and not a legal identifier for the import, so we don't expect it here - Debug.assert(name !== "default"); - - // check exports with the same name - const exportSymbolWithIdenticalName = checker.tryGetMemberInModuleExportsAndProperties(name, moduleSymbol); - if (exportSymbolWithIdenticalName && checkSymbolHasMeaning(exportSymbolWithIdenticalName, currentTokenMeaning)) { - const symbolId = getUniqueSymbolId(exportSymbolWithIdenticalName); - symbolIdActionMap.addActions(symbolId, getCodeActionForImport(moduleSymbol, name)); - } + else { + return [getCodeActionForNewImport()]; } - return symbolIdActionMap.getAllActions(); - - function getImportDeclarations(moduleSymbol: Symbol) { - const moduleSymbolId = getUniqueSymbolId(moduleSymbol); + function getImportDeclarations() { + const moduleSymbolId = getUniqueSymbolId(moduleSymbol, checker); const cached = cachedImportDeclarations[moduleSymbolId]; if (cached) { @@ -224,489 +214,526 @@ namespace ts.codefix { } } - function getUniqueSymbolId(symbol: Symbol) { - return getSymbolId(skipAlias(symbol, checker)); - } - - function checkSymbolHasMeaning(symbol: Symbol, meaning: SemanticMeaning) { - const declarations = symbol.getDeclarations(); - return declarations ? some(symbol.declarations, decl => !!(getMeaningFromDeclaration(decl) & meaning)) : false; + function createChangeTracker() { + return textChanges.ChangeTracker.fromCodeFixContext(context); } - function getCodeActionForImport(moduleSymbol: Symbol, symbolName: string, isDefault?: boolean, isNamespaceImport?: boolean): ImportCodeAction[] { - const existingDeclarations = getImportDeclarations(moduleSymbol); - if (existingDeclarations.length > 0) { - // With an existing import statement, there are more than one actions the user can do. - return getCodeActionsForExistingImport(existingDeclarations); - } - else { - return [getCodeActionForNewImport()]; - } - - function getCodeActionsForExistingImport(declarations: (ImportDeclaration | ImportEqualsDeclaration)[]): ImportCodeAction[] { - const actions: ImportCodeAction[] = []; - - // It is possible that multiple import statements with the same specifier exist in the file. - // e.g. - // - // import * as ns from "foo"; - // import { member1, member2 } from "foo"; - // - // member3/**/ <-- cusor here - // - // 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. - let namespaceImportDeclaration: ImportDeclaration | ImportEqualsDeclaration; - let namedImportDeclaration: ImportDeclaration; - let existingModuleSpecifier: string; - for (const declaration of declarations) { - if (declaration.kind === SyntaxKind.ImportDeclaration) { - const namedBindings = declaration.importClause && declaration.importClause.namedBindings; - if (namedBindings && namedBindings.kind === SyntaxKind.NamespaceImport) { - // case: - // import * as ns from "foo" - namespaceImportDeclaration = declaration; - } - else { - // cases: - // import default from "foo" - // import { bar } from "foo" or combination with the first one - // import "foo" - namedImportDeclaration = declaration; - } - existingModuleSpecifier = declaration.moduleSpecifier.getText(); - } - else { + function getCodeActionsForExistingImport(declarations: (ImportDeclaration | ImportEqualsDeclaration)[]): ImportCodeAction[] { + const actions: ImportCodeAction[] = []; + + // It is possible that multiple import statements with the same specifier exist in the file. + // e.g. + // + // import * as ns from "foo"; + // import { member1, member2 } from "foo"; + // + // member3/**/ <-- cusor here + // + // 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. + let namespaceImportDeclaration: ImportDeclaration | ImportEqualsDeclaration; + let namedImportDeclaration: ImportDeclaration; + let existingModuleSpecifier: string; + for (const declaration of declarations) { + if (declaration.kind === SyntaxKind.ImportDeclaration) { + const namedBindings = declaration.importClause && declaration.importClause.namedBindings; + if (namedBindings && namedBindings.kind === SyntaxKind.NamespaceImport) { // case: - // import foo = require("foo") + // import * as ns from "foo" namespaceImportDeclaration = declaration; - existingModuleSpecifier = getModuleSpecifierFromImportEqualsDeclaration(declaration); } - } - - if (namespaceImportDeclaration) { - actions.push(getCodeActionForNamespaceImport(namespaceImportDeclaration)); - } - - if (!isNamespaceImport && namedImportDeclaration && namedImportDeclaration.importClause && - (namedImportDeclaration.importClause.name || namedImportDeclaration.importClause.namedBindings)) { - /** - * If the existing import declaration already has a named import list, just - * insert the identifier into that list. - */ - const fileTextChanges = getTextChangeForImportClause(namedImportDeclaration.importClause); - const moduleSpecifierWithoutQuotes = stripQuotes(namedImportDeclaration.moduleSpecifier.getText()); - actions.push(createCodeAction( - Diagnostics.Add_0_to_existing_import_declaration_from_1, - [name, moduleSpecifierWithoutQuotes], - fileTextChanges, - "InsertingIntoExistingImport", - moduleSpecifierWithoutQuotes - )); + else { + // cases: + // import default from "foo" + // import { bar } from "foo" or combination with the first one + // import "foo" + namedImportDeclaration = declaration; + } + existingModuleSpecifier = declaration.moduleSpecifier.getText(); } else { - // we need to create a new import statement, but the existing module specifier can be reused. - actions.push(getCodeActionForNewImport(existingModuleSpecifier)); - } - return actions; - - function getModuleSpecifierFromImportEqualsDeclaration(declaration: ImportEqualsDeclaration) { - if (declaration.moduleReference && declaration.moduleReference.kind === SyntaxKind.ExternalModuleReference) { - return declaration.moduleReference.expression.getText(); - } - return declaration.moduleReference.getText(); + // case: + // import foo = require("foo") + namespaceImportDeclaration = declaration; + existingModuleSpecifier = getModuleSpecifierFromImportEqualsDeclaration(declaration); } + } - function getTextChangeForImportClause(importClause: ImportClause): FileTextChanges[] { - const importList = importClause.namedBindings; - const newImportSpecifier = createImportSpecifier(/*propertyName*/ undefined, createIdentifier(name)); - // case 1: - // original text: import default from "module" - // change to: import default, { name } from "module" - // case 2: - // original text: import {} from "module" - // change to: import { name } from "module" - if (!importList || importList.elements.length === 0) { - const newImportClause = createImportClause(importClause.name, createNamedImports([newImportSpecifier])); - return createChangeTracker().replaceNode(sourceFile, importClause, newImportClause).getChanges(); - } + if (symbolToken && namespaceImportDeclaration) { + actions.push(getCodeActionForNamespaceImport(namespaceImportDeclaration)); + } - /** - * If the import list has one import per line, preserve that. Otherwise, insert on same line as last element - * import { - * foo - * } from "./module"; - */ - return createChangeTracker().insertNodeInListAfter( - sourceFile, - importList.elements[importList.elements.length - 1], - newImportSpecifier).getChanges(); - } + if (!isNamespaceImport && namedImportDeclaration && namedImportDeclaration.importClause && + (namedImportDeclaration.importClause.name || namedImportDeclaration.importClause.namedBindings)) { + /** + * If the existing import declaration already has a named import list, just + * insert the identifier into that list. + */ + const fileTextChanges = getTextChangeForImportClause(namedImportDeclaration.importClause); + const moduleSpecifierWithoutQuotes = stripQuotes(namedImportDeclaration.moduleSpecifier.getText()); + actions.push(createCodeAction( + Diagnostics.Add_0_to_existing_import_declaration_from_1, + [name, moduleSpecifierWithoutQuotes], + fileTextChanges, + "InsertingIntoExistingImport", + moduleSpecifierWithoutQuotes + )); + } + else { + // we need to create a new import statement, but the existing module specifier can be reused. + actions.push(getCodeActionForNewImport(existingModuleSpecifier)); + } + return actions; - function getCodeActionForNamespaceImport(declaration: ImportDeclaration | ImportEqualsDeclaration): ImportCodeAction { - let namespacePrefix: string; - if (declaration.kind === SyntaxKind.ImportDeclaration) { - namespacePrefix = (declaration.importClause.namedBindings).name.getText(); - } - else { - namespacePrefix = declaration.name.getText(); - } - namespacePrefix = stripQuotes(namespacePrefix); - - /** - * Cases: - * import * as ns from "mod" - * import default, * as ns from "mod" - * import ns = require("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" - */ - return createCodeAction( - Diagnostics.Change_0_to_1, - [name, `${namespacePrefix}.${name}`], - createChangeTracker().replaceNode(sourceFile, token, createPropertyAccess(createIdentifier(namespacePrefix), name)).getChanges(), - "CodeChange" - ); + function getModuleSpecifierFromImportEqualsDeclaration(declaration: ImportEqualsDeclaration) { + if (declaration.moduleReference && declaration.moduleReference.kind === SyntaxKind.ExternalModuleReference) { + return declaration.moduleReference.expression.getText(); } + return declaration.moduleReference.getText(); } - function getCodeActionForNewImport(moduleSpecifier?: string): ImportCodeAction { - if (!lastImportDeclaration) { - // insert after any existing imports - for (let i = sourceFile.statements.length - 1; i >= 0; i--) { - const statement = sourceFile.statements[i]; - if (statement.kind === SyntaxKind.ImportEqualsDeclaration || statement.kind === SyntaxKind.ImportDeclaration) { - lastImportDeclaration = statement; - break; - } - } + function getTextChangeForImportClause(importClause: ImportClause): FileTextChanges[] { + const importList = importClause.namedBindings; + const newImportSpecifier = createImportSpecifier(/*propertyName*/ undefined, createIdentifier(name)); + // case 1: + // original text: import default from "module" + // change to: import default, { name } from "module" + // case 2: + // original text: import {} from "module" + // change to: import { name } from "module" + if (!importList || importList.elements.length === 0) { + const newImportClause = createImportClause(importClause.name, createNamedImports([newImportSpecifier])); + return createChangeTracker().replaceNode(sourceFile, importClause, newImportClause).getChanges(); } - const getCanonicalFileName = createGetCanonicalFileName(useCaseSensitiveFileNames); - const moduleSpecifierWithoutQuotes = stripQuotes(moduleSpecifier || getModuleSpecifierForNewImport()); - const changeTracker = createChangeTracker(); - const importClause = isDefault - ? createImportClause(createIdentifier(symbolName), /*namedBindings*/ undefined) - : isNamespaceImport - ? createImportClause(/*name*/ undefined, createNamespaceImport(createIdentifier(symbolName))) - : createImportClause(/*name*/ undefined, createNamedImports([createImportSpecifier(/*propertyName*/ undefined, createIdentifier(symbolName))])); - const moduleSpecifierLiteral = createLiteral(moduleSpecifierWithoutQuotes); - moduleSpecifierLiteral.singleQuote = getSingleQuoteStyleFromExistingImports(); - const importDecl = createImportDeclaration(/*decorators*/ undefined, /*modifiers*/ undefined, importClause, moduleSpecifierLiteral); - if (!lastImportDeclaration) { - changeTracker.insertNodeAt(sourceFile, getSourceFileImportLocation(sourceFile), importDecl, { suffix: `${context.newLineCharacter}${context.newLineCharacter}` }); + /** + * If the import list has one import per line, preserve that. Otherwise, insert on same line as last element + * import { + * foo + * } from "./module"; + */ + return createChangeTracker().insertNodeInListAfter( + sourceFile, + importList.elements[importList.elements.length - 1], + newImportSpecifier).getChanges(); + } + + function getCodeActionForNamespaceImport(declaration: ImportDeclaration | ImportEqualsDeclaration): ImportCodeAction { + let namespacePrefix: string; + if (declaration.kind === SyntaxKind.ImportDeclaration) { + namespacePrefix = (declaration.importClause.namedBindings).name.getText(); } else { - changeTracker.insertNodeAfter(sourceFile, lastImportDeclaration, importDecl, { suffix: context.newLineCharacter }); + namespacePrefix = declaration.name.getText(); } - - // if this file doesn't have any import statements, insert an import statement and then insert a new line - // between the only import statement and user code. Otherwise just insert the statement because chances - // are there are already a new line seperating code and import statements. + namespacePrefix = stripQuotes(namespacePrefix); + + /** + * Cases: + * import * as ns from "mod" + * import default, * as ns from "mod" + * import ns = require("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" + */ return createCodeAction( - Diagnostics.Import_0_from_1, - [symbolName, `"${moduleSpecifierWithoutQuotes}"`], - changeTracker.getChanges(), - "NewImport", - moduleSpecifierWithoutQuotes + Diagnostics.Change_0_to_1, + [name, `${namespacePrefix}.${name}`], + createChangeTracker().replaceNode(sourceFile, symbolToken, createPropertyAccess(createIdentifier(namespacePrefix), name)).getChanges(), + "CodeChange" ); + } + } - function getSourceFileImportLocation(node: SourceFile) { - // For a source file, it is possible there are detached comments we should not skip - const text = node.text; - let ranges = getLeadingCommentRanges(text, 0); - if (!ranges) return 0; - let position = 0; - // However we should still skip a pinned comment at the top - if (ranges.length && ranges[0].kind === SyntaxKind.MultiLineCommentTrivia && isPinnedComment(text, ranges[0])) { - position = ranges[0].end + 1; - ranges = ranges.slice(1); - } - // As well as any triple slash references - for (const range of ranges) { - if (range.kind === SyntaxKind.SingleLineCommentTrivia && isRecognizedTripleSlashComment(node.text, range.pos, range.end)) { - position = range.end + 1; - continue; - } + function getCodeActionForNewImport(moduleSpecifier?: string): ImportCodeAction { + if (!lastImportDeclaration) { + // insert after any existing imports + for (let i = sourceFile.statements.length - 1; i >= 0; i--) { + const statement = sourceFile.statements[i]; + if (statement.kind === SyntaxKind.ImportEqualsDeclaration || statement.kind === SyntaxKind.ImportDeclaration) { + lastImportDeclaration = statement; break; } - return position; } + } - function getSingleQuoteStyleFromExistingImports() { - const firstModuleSpecifier = forEach(sourceFile.statements, node => { - if (isImportDeclaration(node) || isExportDeclaration(node)) { - if (node.moduleSpecifier && isStringLiteral(node.moduleSpecifier)) { - return node.moduleSpecifier; - } + const moduleSpecifierWithoutQuotes = stripQuotes(moduleSpecifier || getModuleSpecifierForNewImport()); + const changeTracker = createChangeTracker(); + const importClause = isDefault + ? createImportClause(createIdentifier(symbolName), /*namedBindings*/ undefined) + : isNamespaceImport + ? createImportClause(/*name*/ undefined, createNamespaceImport(createIdentifier(symbolName))) + : createImportClause(/*name*/ undefined, createNamedImports([createImportSpecifier(/*propertyName*/ undefined, createIdentifier(symbolName))])); + const moduleSpecifierLiteral = createLiteral(moduleSpecifierWithoutQuotes); + moduleSpecifierLiteral.singleQuote = getSingleQuoteStyleFromExistingImports(); + const importDecl = createImportDeclaration(/*decorators*/ undefined, /*modifiers*/ undefined, importClause, moduleSpecifierLiteral); + if (!lastImportDeclaration) { + changeTracker.insertNodeAt(sourceFile, getSourceFileImportLocation(sourceFile), importDecl, { suffix: `${context.newLineCharacter}${context.newLineCharacter}` }); + } + else { + changeTracker.insertNodeAfter(sourceFile, lastImportDeclaration, importDecl, { suffix: newLineCharacter }); + } + + // if this file doesn't have any import statements, insert an import statement and then insert a new line + // between the only import statement and user code. Otherwise just insert the statement because chances + // are there are already a new line seperating code and import statements. + return createCodeAction( + Diagnostics.Import_0_from_1, + [symbolName, `"${moduleSpecifierWithoutQuotes}"`], + changeTracker.getChanges(), + "NewImport", + moduleSpecifierWithoutQuotes + ); + + function getSourceFileImportLocation(node: SourceFile) { + // For a source file, it is possible there are detached comments we should not skip + const text = node.text; + let ranges = getLeadingCommentRanges(text, 0); + if (!ranges) return 0; + let position = 0; + // However we should still skip a pinned comment at the top + if (ranges.length && ranges[0].kind === SyntaxKind.MultiLineCommentTrivia && isPinnedComment(text, ranges[0])) { + position = ranges[0].end + 1; + ranges = ranges.slice(1); + } + // As well as any triple slash references + for (const range of ranges) { + if (range.kind === SyntaxKind.SingleLineCommentTrivia && isRecognizedTripleSlashComment(node.text, range.pos, range.end)) { + position = range.end + 1; + continue; + } + break; + } + return position; + } + + function getSingleQuoteStyleFromExistingImports() { + const firstModuleSpecifier = forEach(sourceFile.statements, node => { + if (isImportDeclaration(node) || isExportDeclaration(node)) { + if (node.moduleSpecifier && isStringLiteral(node.moduleSpecifier)) { + return node.moduleSpecifier; } - else if (isImportEqualsDeclaration(node)) { - if (isExternalModuleReference(node.moduleReference) && isStringLiteral(node.moduleReference.expression)) { - return node.moduleReference.expression; - } + } + else if (isImportEqualsDeclaration(node)) { + if (isExternalModuleReference(node.moduleReference) && isStringLiteral(node.moduleReference.expression)) { + return node.moduleReference.expression; } - }); - if (firstModuleSpecifier) { - return sourceFile.text.charCodeAt(firstModuleSpecifier.getStart()) === CharacterCodes.singleQuote; } + }); + if (firstModuleSpecifier) { + return sourceFile.text.charCodeAt(firstModuleSpecifier.getStart()) === CharacterCodes.singleQuote; } + } - function getModuleSpecifierForNewImport() { - const fileName = sourceFile.fileName; - const moduleFileName = moduleSymbol.valueDeclaration.getSourceFile().fileName; - const sourceDirectory = getDirectoryPath(fileName); - const options = context.program.getCompilerOptions(); - - return tryGetModuleNameFromAmbientModule() || - tryGetModuleNameFromTypeRoots() || - tryGetModuleNameAsNodeModule() || - tryGetModuleNameFromBaseUrl() || - tryGetModuleNameFromRootDirs() || - removeFileExtension(getRelativePath(moduleFileName, sourceDirectory)); - - function tryGetModuleNameFromAmbientModule(): string { - const decl = moduleSymbol.valueDeclaration; - if (isModuleDeclaration(decl) && isStringLiteral(decl.name)) { - return decl.name.text; - } + function getModuleSpecifierForNewImport() { + const fileName = sourceFile.fileName; + const moduleFileName = moduleSymbol.valueDeclaration.getSourceFile().fileName; + const sourceDirectory = getDirectoryPath(fileName); + const options = compilerOptions; + + return tryGetModuleNameFromAmbientModule() || + tryGetModuleNameFromTypeRoots() || + tryGetModuleNameAsNodeModule() || + tryGetModuleNameFromBaseUrl() || + tryGetModuleNameFromRootDirs() || + removeFileExtension(getRelativePath(moduleFileName, sourceDirectory)); + + function tryGetModuleNameFromAmbientModule(): string { + const decl = moduleSymbol.valueDeclaration; + if (isModuleDeclaration(decl) && isStringLiteral(decl.name)) { + return decl.name.text; } + } - function tryGetModuleNameFromBaseUrl() { - if (!options.baseUrl) { - return undefined; - } + function tryGetModuleNameFromBaseUrl() { + if (!options.baseUrl) { + return undefined; + } - let relativeName = getRelativePathIfInDirectory(moduleFileName, options.baseUrl); - if (!relativeName) { - return undefined; - } + let relativeName = getRelativePathIfInDirectory(moduleFileName, options.baseUrl); + if (!relativeName) { + return undefined; + } - const relativeNameWithIndex = removeFileExtension(relativeName); - relativeName = removeExtensionAndIndexPostFix(relativeName); + const relativeNameWithIndex = removeFileExtension(relativeName); + relativeName = removeExtensionAndIndexPostFix(relativeName); - if (options.paths) { - for (const key in options.paths) { - for (const pattern of options.paths[key]) { - const indexOfStar = pattern.indexOf("*"); - if (indexOfStar === 0 && pattern.length === 1) { - continue; - } - else if (indexOfStar !== -1) { - const prefix = pattern.substr(0, indexOfStar); - const suffix = pattern.substr(indexOfStar + 1); - if (relativeName.length >= prefix.length + suffix.length && - startsWith(relativeName, prefix) && - endsWith(relativeName, suffix)) { - const matchedStar = relativeName.substr(prefix.length, relativeName.length - suffix.length); - return key.replace("\*", matchedStar); - } - } - else if (pattern === relativeName || pattern === relativeNameWithIndex) { - return key; + if (options.paths) { + for (const key in options.paths) { + for (const pattern of options.paths[key]) { + const indexOfStar = pattern.indexOf("*"); + if (indexOfStar === 0 && pattern.length === 1) { + continue; + } + else if (indexOfStar !== -1) { + const prefix = pattern.substr(0, indexOfStar); + const suffix = pattern.substr(indexOfStar + 1); + if (relativeName.length >= prefix.length + suffix.length && + startsWith(relativeName, prefix) && + endsWith(relativeName, suffix)) { + const matchedStar = relativeName.substr(prefix.length, relativeName.length - suffix.length); + return key.replace("\*", matchedStar); } } + else if (pattern === relativeName || pattern === relativeNameWithIndex) { + return key; + } } } - - return relativeName; } - function tryGetModuleNameFromRootDirs() { - if (options.rootDirs) { - const normalizedTargetPath = getPathRelativeToRootDirs(moduleFileName, options.rootDirs); - const normalizedSourcePath = getPathRelativeToRootDirs(sourceDirectory, options.rootDirs); - if (normalizedTargetPath !== undefined) { - const relativePath = normalizedSourcePath !== undefined ? getRelativePath(normalizedTargetPath, normalizedSourcePath) : normalizedTargetPath; - return removeFileExtension(relativePath); - } + return relativeName; + } + + function tryGetModuleNameFromRootDirs() { + if (options.rootDirs) { + const normalizedTargetPath = getPathRelativeToRootDirs(moduleFileName, options.rootDirs); + const normalizedSourcePath = getPathRelativeToRootDirs(sourceDirectory, options.rootDirs); + if (normalizedTargetPath !== undefined) { + const relativePath = normalizedSourcePath !== undefined ? getRelativePath(normalizedTargetPath, normalizedSourcePath) : normalizedTargetPath; + return removeFileExtension(relativePath); } - return undefined; } + return undefined; + } - function tryGetModuleNameFromTypeRoots() { - const typeRoots = getEffectiveTypeRoots(options, context.host); - if (typeRoots) { - const normalizedTypeRoots = map(typeRoots, typeRoot => toPath(typeRoot, /*basePath*/ undefined, getCanonicalFileName)); - for (const typeRoot of normalizedTypeRoots) { - if (startsWith(moduleFileName, typeRoot)) { - const relativeFileName = moduleFileName.substring(typeRoot.length + 1); - return removeExtensionAndIndexPostFix(relativeFileName); - } + function tryGetModuleNameFromTypeRoots() { + const typeRoots = getEffectiveTypeRoots(options, host); + if (typeRoots) { + const normalizedTypeRoots = map(typeRoots, typeRoot => toPath(typeRoot, /*basePath*/ undefined, getCanonicalFileName)); + for (const typeRoot of normalizedTypeRoots) { + if (startsWith(moduleFileName, typeRoot)) { + const relativeFileName = moduleFileName.substring(typeRoot.length + 1); + return removeExtensionAndIndexPostFix(relativeFileName); } } } + } - function tryGetModuleNameAsNodeModule() { - if (getEmitModuleResolutionKind(options) !== ModuleResolutionKind.NodeJs) { - // nothing to do here - return undefined; - } + function tryGetModuleNameAsNodeModule() { + if (getEmitModuleResolutionKind(options) !== ModuleResolutionKind.NodeJs) { + // nothing to do here + return undefined; + } - const parts = getNodeModulePathParts(moduleFileName); + const parts = getNodeModulePathParts(moduleFileName); - if (!parts) { - return undefined; - } + if (!parts) { + return undefined; + } - // Simplify the full file path to something that can be resolved by Node. - - // If the module could be imported by a directory name, use that directory's name - let moduleSpecifier = getDirectoryOrExtensionlessFileName(moduleFileName); - // Get a path that's relative to node_modules or the importing file's path - moduleSpecifier = getNodeResolvablePath(moduleSpecifier); - // If the module was found in @types, get the actual Node package name - return getPackageNameFromAtTypesDirectory(moduleSpecifier); - - function getDirectoryOrExtensionlessFileName(path: string): string { - // If the file is the main module, it can be imported by the package name - const packageRootPath = path.substring(0, parts.packageRootIndex); - const packageJsonPath = combinePaths(packageRootPath, "package.json"); - if (context.host.fileExists(packageJsonPath)) { - const packageJsonContent = JSON.parse(context.host.readFile(packageJsonPath)); - if (packageJsonContent) { - const mainFileRelative = packageJsonContent.typings || packageJsonContent.types || packageJsonContent.main; - if (mainFileRelative) { - const mainExportFile = toPath(mainFileRelative, packageRootPath, getCanonicalFileName); - if (mainExportFile === getCanonicalFileName(path)) { - return packageRootPath; - } + // Simplify the full file path to something that can be resolved by Node. + + // If the module could be imported by a directory name, use that directory's name + let moduleSpecifier = getDirectoryOrExtensionlessFileName(moduleFileName); + // Get a path that's relative to node_modules or the importing file's path + moduleSpecifier = getNodeResolvablePath(moduleSpecifier); + // If the module was found in @types, get the actual Node package name + return getPackageNameFromAtTypesDirectory(moduleSpecifier); + + function getDirectoryOrExtensionlessFileName(path: string): string { + // If the file is the main module, it can be imported by the package name + const packageRootPath = path.substring(0, parts.packageRootIndex); + const packageJsonPath = combinePaths(packageRootPath, "package.json"); + if (host.fileExists(packageJsonPath)) { + const packageJsonContent = JSON.parse(host.readFile(packageJsonPath)); + if (packageJsonContent) { + const mainFileRelative = packageJsonContent.typings || packageJsonContent.types || packageJsonContent.main; + if (mainFileRelative) { + const mainExportFile = toPath(mainFileRelative, packageRootPath, getCanonicalFileName); + if (mainExportFile === getCanonicalFileName(path)) { + return packageRootPath; } } } + } - // We still have a file name - remove the extension - const fullModulePathWithoutExtension = removeFileExtension(path); - - // If the file is /index, it can be imported by its directory name - if (getCanonicalFileName(fullModulePathWithoutExtension.substring(parts.fileNameIndex)) === "/index") { - return fullModulePathWithoutExtension.substring(0, parts.fileNameIndex); - } + // We still have a file name - remove the extension + const fullModulePathWithoutExtension = removeFileExtension(path); - return fullModulePathWithoutExtension; + // If the file is /index, it can be imported by its directory name + if (getCanonicalFileName(fullModulePathWithoutExtension.substring(parts.fileNameIndex)) === "/index") { + return fullModulePathWithoutExtension.substring(0, parts.fileNameIndex); } - function getNodeResolvablePath(path: string): string { - const basePath = path.substring(0, parts.topLevelNodeModulesIndex); - if (sourceDirectory.indexOf(basePath) === 0) { - // if node_modules folder is in this folder or any of its parent folders, no need to keep it. - return path.substring(parts.topLevelPackageNameIndex + 1); - } - else { - return getRelativePath(path, sourceDirectory); - } - } + return fullModulePathWithoutExtension; } - } - function getNodeModulePathParts(fullPath: string) { - // If fullPath can't be valid module file within node_modules, returns undefined. - // Example of expected pattern: /base/path/node_modules/[@scope/otherpackage/@otherscope/node_modules/]package/[subdirectory/]file.js - // Returns indices: ^ ^ ^ ^ - - let topLevelNodeModulesIndex = 0; - let topLevelPackageNameIndex = 0; - let packageRootIndex = 0; - let fileNameIndex = 0; - - const enum States { - BeforeNodeModules, - NodeModules, - Scope, - PackageContent - } - - let partStart = 0; - let partEnd = 0; - let state = States.BeforeNodeModules; - - while (partEnd >= 0) { - partStart = partEnd; - partEnd = fullPath.indexOf("/", partStart + 1); - switch (state) { - case States.BeforeNodeModules: - if (fullPath.indexOf("/node_modules/", partStart) === partStart) { - topLevelNodeModulesIndex = partStart; - topLevelPackageNameIndex = partEnd; - state = States.NodeModules; - } - break; - case States.NodeModules: - case States.Scope: - if (state === States.NodeModules && fullPath.charAt(partStart + 1) === "@") { - state = States.Scope; - } - else { - packageRootIndex = partEnd; - state = States.PackageContent; - } - break; - case States.PackageContent: - if (fullPath.indexOf("/node_modules/", partStart) === partStart) { - state = States.NodeModules; - } - else { - state = States.PackageContent; - } - break; + function getNodeResolvablePath(path: string): string { + const basePath = path.substring(0, parts.topLevelNodeModulesIndex); + if (sourceDirectory.indexOf(basePath) === 0) { + // if node_modules folder is in this folder or any of its parent folders, no need to keep it. + return path.substring(parts.topLevelPackageNameIndex + 1); + } + else { + return getRelativePath(path, sourceDirectory); } } + } + } - fileNameIndex = partStart; - - return state > States.NodeModules ? { topLevelNodeModulesIndex, topLevelPackageNameIndex, packageRootIndex, fileNameIndex } : undefined; + function getNodeModulePathParts(fullPath: string) { + // If fullPath can't be valid module file within node_modules, returns undefined. + // Example of expected pattern: /base/path/node_modules/[@scope/otherpackage/@otherscope/node_modules/]package/[subdirectory/]file.js + // Returns indices: ^ ^ ^ ^ + + let topLevelNodeModulesIndex = 0; + let topLevelPackageNameIndex = 0; + let packageRootIndex = 0; + let fileNameIndex = 0; + + const enum States { + BeforeNodeModules, + NodeModules, + Scope, + PackageContent } - function getPathRelativeToRootDirs(path: string, rootDirs: string[]) { - for (const rootDir of rootDirs) { - const relativeName = getRelativePathIfInDirectory(path, rootDir); - if (relativeName !== undefined) { - return relativeName; - } + let partStart = 0; + let partEnd = 0; + let state = States.BeforeNodeModules; + + while (partEnd >= 0) { + partStart = partEnd; + partEnd = fullPath.indexOf("/", partStart + 1); + switch (state) { + case States.BeforeNodeModules: + if (fullPath.indexOf("/node_modules/", partStart) === partStart) { + topLevelNodeModulesIndex = partStart; + topLevelPackageNameIndex = partEnd; + state = States.NodeModules; + } + break; + case States.NodeModules: + case States.Scope: + if (state === States.NodeModules && fullPath.charAt(partStart + 1) === "@") { + state = States.Scope; + } + else { + packageRootIndex = partEnd; + state = States.PackageContent; + } + break; + case States.PackageContent: + if (fullPath.indexOf("/node_modules/", partStart) === partStart) { + state = States.NodeModules; + } + else { + state = States.PackageContent; + } + break; } - return undefined; } - function removeExtensionAndIndexPostFix(fileName: string) { - fileName = removeFileExtension(fileName); - if (endsWith(fileName, "/index")) { - fileName = fileName.substr(0, fileName.length - 6/* "/index".length */); + fileNameIndex = partStart; + + return state > States.NodeModules ? { topLevelNodeModulesIndex, topLevelPackageNameIndex, packageRootIndex, fileNameIndex } : undefined; + } + + function getPathRelativeToRootDirs(path: string, rootDirs: string[]) { + for (const rootDir of rootDirs) { + const relativeName = getRelativePathIfInDirectory(path, rootDir); + if (relativeName !== undefined) { + return relativeName; } - return fileName; } + return undefined; + } - function getRelativePathIfInDirectory(path: string, directoryPath: string) { - const relativePath = getRelativePathToDirectoryOrUrl(directoryPath, path, directoryPath, getCanonicalFileName, /*isAbsolutePathAnUrl*/ false); - return isRootedDiskPath(relativePath) || startsWith(relativePath, "..") ? undefined : relativePath; + function removeExtensionAndIndexPostFix(fileName: string) { + fileName = removeFileExtension(fileName); + if (endsWith(fileName, "/index")) { + fileName = fileName.substr(0, fileName.length - 6/* "/index".length */); } + return fileName; + } - function getRelativePath(path: string, directoryPath: string) { - const relativePath = getRelativePathToDirectoryOrUrl(directoryPath, path, directoryPath, getCanonicalFileName, /*isAbsolutePathAnUrl*/ false); - return !pathIsRelative(relativePath) ? "./" + relativePath : relativePath; - } + function getRelativePathIfInDirectory(path: string, directoryPath: string) { + const relativePath = getRelativePathToDirectoryOrUrl(directoryPath, path, directoryPath, getCanonicalFileName, /*isAbsolutePathAnUrl*/ false); + return isRootedDiskPath(relativePath) || startsWith(relativePath, "..") ? undefined : relativePath; } + function getRelativePath(path: string, directoryPath: string) { + const relativePath = getRelativePathToDirectoryOrUrl(directoryPath, path, directoryPath, getCanonicalFileName, /*isAbsolutePathAnUrl*/ false); + return !pathIsRelative(relativePath) ? "./" + relativePath : relativePath; + } } - function createChangeTracker() { - return textChanges.ChangeTracker.fromCodeFixContext(context); + } + + function getImportCodeActions(context: CodeFixContext): ImportCodeAction[] { + const sourceFile = context.sourceFile; + const allSourceFiles = context.program.getSourceFiles(); + const importFixContext = convertToImportCodeFixContext(context); + + const checker = importFixContext.checker; + const token = importFixContext.symbolToken; + const symbolIdActionMap = new ImportCodeActionMap(); + const currentTokenMeaning = getMeaningFromLocation(token); + + const name = importFixContext.symbolName; + + if (context.errorCode === Diagnostics._0_refers_to_a_UMD_global_but_the_current_file_is_a_module_Consider_adding_an_import_instead.code) { + const umdSymbol = checker.getSymbolAtLocation(token); + let symbol: ts.Symbol; + let symbolName: string; + if (umdSymbol.flags & ts.SymbolFlags.Alias) { + symbol = checker.getAliasedSymbol(umdSymbol); + symbolName = name; + } + else if (isJsxOpeningLikeElement(token.parent) && token.parent.tagName === token) { + // The error wasn't for the symbolAtLocation, it was for the JSX tag itself, which needs access to e.g. `React`. + symbol = checker.getAliasedSymbol(checker.resolveName(checker.getJsxNamespace(), token.parent.tagName, SymbolFlags.Value)); + symbolName = symbol.name; + } + else { + Debug.fail("Either the symbol or the JSX namespace should be a UMD global if we got here"); + } + + return getCodeActionForImport(symbol, importFixContext, symbolName, /*isDefault*/ false, /*isNamespaceImport*/ true); } - function createCodeAction( - description: DiagnosticMessage, - diagnosticArgs: string[], - changes: FileTextChanges[], - kind: ImportCodeActionKind, - moduleSpecifier?: string): ImportCodeAction { - return { - description: formatMessage.apply(undefined, [undefined, description].concat(diagnosticArgs)), - changes, - kind, - moduleSpecifier - }; + const candidateModules = checker.getAmbientModules(); + for (const otherSourceFile of allSourceFiles) { + if (otherSourceFile !== sourceFile && isExternalOrCommonJsModule(otherSourceFile)) { + candidateModules.push(otherSourceFile.symbol); + } + } + + for (const moduleSymbol of candidateModules) { + context.cancellationToken.throwIfCancellationRequested(); + + // check the default export + const defaultExport = checker.tryGetMemberInModuleExports("default", moduleSymbol); + if (defaultExport) { + const localSymbol = getLocalSymbolForExportDefault(defaultExport); + if (localSymbol && localSymbol.escapedName === name && checkSymbolHasMeaning(localSymbol, currentTokenMeaning)) { + // check if this symbol is already used + const symbolId = getUniqueSymbolId(localSymbol, checker); + symbolIdActionMap.addActions(symbolId, getCodeActionForImport(moduleSymbol, importFixContext, name, /*isNamespaceImport*/ true)); + } + } + + // "default" is a keyword and not a legal identifier for the import, so we don't expect it here + Debug.assert(name !== "default"); + + // check exports with the same name + const exportSymbolWithIdenticalName = checker.tryGetMemberInModuleExportsAndProperties(name, moduleSymbol); + if (exportSymbolWithIdenticalName && checkSymbolHasMeaning(exportSymbolWithIdenticalName, currentTokenMeaning)) { + const symbolId = getUniqueSymbolId(exportSymbolWithIdenticalName, checker); + symbolIdActionMap.addActions(symbolId, getCodeActionForImport(moduleSymbol, importFixContext, name)); + } + } + + return symbolIdActionMap.getAllActions(); + + function checkSymbolHasMeaning(symbol: Symbol, meaning: SemanticMeaning) { + const declarations = symbol.getDeclarations(); + return declarations ? some(symbol.declarations, decl => !!(getMeaningFromDeclaration(decl) & meaning)) : false; } } } diff --git a/src/services/completions.ts b/src/services/completions.ts index fde07aa78f6b9..b0f26b8da985a 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -4,13 +4,15 @@ namespace ts.Completions { export type Log = (message: string) => void; + export type SymbolOriginInfo = { moduleSymbol: Symbol, isDefaultExport?: boolean }; + const enum KeywordCompletionFilters { None, ClassElementKeywords, // Keywords at class keyword ConstructorParameterKeywords, // Keywords at constructor parameter } - export function getCompletionsAtPosition(host: LanguageServiceHost, typeChecker: TypeChecker, log: Log, compilerOptions: CompilerOptions, sourceFile: SourceFile, position: number): CompletionInfo | undefined { + export function getCompletionsAtPosition(host: LanguageServiceHost, typeChecker: TypeChecker, log: Log, compilerOptions: CompilerOptions, sourceFile: SourceFile, position: number, allSourceFiles: SourceFile[]): CompletionInfo | undefined { if (isInReferenceComment(sourceFile, position)) { return PathCompletions.getTripleSlashReferenceCompletion(sourceFile, position, compilerOptions, host); } @@ -19,12 +21,12 @@ namespace ts.Completions { return getStringLiteralCompletionEntries(sourceFile, position, typeChecker, compilerOptions, host, log); } - const completionData = getCompletionData(typeChecker, log, sourceFile, position); + const completionData = getCompletionData(typeChecker, log, sourceFile, position, allSourceFiles); if (!completionData) { return undefined; } - const { symbols, isGlobalCompletion, isMemberCompletion, isNewIdentifierLocation, location, request, keywordFilters } = completionData; + const { symbols, isGlobalCompletion, isMemberCompletion, isNewIdentifierLocation, location, request, keywordFilters, symbolToOriginInfoMap } = completionData; if (sourceFile.languageVariant === LanguageVariant.JSX && location && location.parent && location.parent.kind === SyntaxKind.JsxClosingElement) { @@ -56,7 +58,7 @@ namespace ts.Completions { const entries: CompletionEntry[] = []; if (isSourceFileJavaScript(sourceFile)) { - const uniqueNames = getCompletionEntriesFromSymbols(symbols, entries, location, /*performCharacterChecks*/ true, typeChecker, compilerOptions.target, log); + const uniqueNames = getCompletionEntriesFromSymbols(symbols, entries, location, /*performCharacterChecks*/ true, typeChecker, compilerOptions.target, log, symbolToOriginInfoMap); getJavaScriptCompletionEntries(sourceFile, location.pos, uniqueNames, compilerOptions.target, entries); } else { @@ -64,7 +66,7 @@ namespace ts.Completions { return undefined; } - getCompletionEntriesFromSymbols(symbols, entries, location, /*performCharacterChecks*/ true, typeChecker, compilerOptions.target, log); + getCompletionEntriesFromSymbols(symbols, entries, location, /*performCharacterChecks*/ true, typeChecker, compilerOptions.target, log, symbolToOriginInfoMap); } // TODO add filter for keyword based on type/value/namespace and also location @@ -134,7 +136,7 @@ namespace ts.Completions { }; } - function getCompletionEntriesFromSymbols(symbols: Symbol[], entries: Push, location: Node, performCharacterChecks: boolean, typeChecker: TypeChecker, target: ScriptTarget, log: Log): Map { + function getCompletionEntriesFromSymbols(symbols: Symbol[], entries: Push, location: Node, performCharacterChecks: boolean, typeChecker: TypeChecker, target: ScriptTarget, log: Log, symbolToOriginInfoMap?: Map): Map { const start = timestamp(); const uniqueNames = createMap(); if (symbols) { @@ -143,6 +145,9 @@ namespace ts.Completions { if (entry) { const id = entry.name; if (!uniqueNames.has(id)) { + if (symbolToOriginInfoMap && symbolToOriginInfoMap.has(getUniqueSymbolIdAsString(symbol, typeChecker))) { + entry.hasAction = true; + } entries.push(entry); uniqueNames.set(id, true); } @@ -298,11 +303,12 @@ namespace ts.Completions { } } - export function getCompletionEntryDetails(typeChecker: TypeChecker, log: (message: string) => void, compilerOptions: CompilerOptions, sourceFile: SourceFile, position: number, entryName: string): CompletionEntryDetails { + export function getCompletionEntryDetails(typeChecker: TypeChecker, log: (message: string) => void, compilerOptions: CompilerOptions, sourceFile: SourceFile, position: number, entryName: string, allSourceFiles: SourceFile[], host?: LanguageServiceHost, rulesProvider?: formatting.RulesProvider): CompletionEntryDetails { + // Compute all the completion symbols again. - const completionData = getCompletionData(typeChecker, log, sourceFile, position); + const completionData = getCompletionData(typeChecker, log, sourceFile, position, allSourceFiles); if (completionData) { - const { symbols, location } = completionData; + const { symbols, location, symbolToOriginInfoMap } = completionData; // Find the symbol with the matching entry name. // We don't need to perform character checks here because we're only comparing the @@ -311,6 +317,26 @@ namespace ts.Completions { const symbol = forEach(symbols, s => getCompletionEntryDisplayNameForSymbol(s, compilerOptions.target, /*performCharacterChecks*/ false) === entryName ? s : undefined); if (symbol) { + let codeActions: CodeAction[]; + if (host && rulesProvider) { + const symbolOriginInfo = symbolToOriginInfoMap.get(getUniqueSymbolIdAsString(symbol, typeChecker)); + if (symbolOriginInfo) { + const useCaseSensitiveFileNames = host.useCaseSensitiveFileNames ? host.useCaseSensitiveFileNames() : false; + const context: codefix.ImportCodeFixContext = { + host, + checker: typeChecker, + newLineCharacter: host.getNewLine(), + compilerOptions, + sourceFile, + rulesProvider, + symbolName: symbol.name, + getCanonicalFileName: createGetCanonicalFileName(useCaseSensitiveFileNames) + }; + + codeActions = codefix.getCodeActionForImport(/*moduleSymbol*/ symbolOriginInfo.moduleSymbol, context, context.symbolName, /*isDefault*/ symbolOriginInfo.isDefaultExport); + } + } + const { displayParts, documentation, symbolKind, tags } = SymbolDisplay.getSymbolDisplayPartsDocumentationAndSymbolKind(typeChecker, symbol, sourceFile, location, location, SemanticMeaning.All); return { name: entryName, @@ -318,7 +344,8 @@ namespace ts.Completions { kind: symbolKind, displayParts, documentation, - tags + tags, + codeActions }; } } @@ -335,16 +362,17 @@ namespace ts.Completions { kindModifiers: ScriptElementKindModifier.none, displayParts: [displayPart(entryName, SymbolDisplayPartKind.keyword)], documentation: undefined, - tags: undefined + tags: undefined, + codeActions: undefined }; } return undefined; } - export function getCompletionEntrySymbol(typeChecker: TypeChecker, log: (message: string) => void, compilerOptions: CompilerOptions, sourceFile: SourceFile, position: number, entryName: string): Symbol | undefined { + export function getCompletionEntrySymbol(typeChecker: TypeChecker, log: (message: string) => void, compilerOptions: CompilerOptions, sourceFile: SourceFile, position: number, entryName: string, allSourceFiles: SourceFile[]): Symbol | undefined { // Compute all the completion symbols again. - const completionData = getCompletionData(typeChecker, log, sourceFile, position); + const completionData = getCompletionData(typeChecker, log, sourceFile, position, allSourceFiles); // Find the symbol with the matching entry name. // We don't need to perform character checks here because we're only comparing the // name against 'entryName' (which is known to be good), not building a new @@ -361,10 +389,11 @@ namespace ts.Completions { isRightOfDot: boolean; request?: Request; keywordFilters: KeywordCompletionFilters; + symbolToOriginInfoMap: Map; } type Request = { kind: "JsDocTagName" } | { kind: "JsDocTag" } | { kind: "JsDocParameterName", tag: JSDocParameterTag }; - function getCompletionData(typeChecker: TypeChecker, log: (message: string) => void, sourceFile: SourceFile, position: number): CompletionData | undefined { + function getCompletionData(typeChecker: TypeChecker, log: (message: string) => void, sourceFile: SourceFile, position: number, allSourceFiles: SourceFile[]): CompletionData | undefined { const isJavaScriptFile = isSourceFileJavaScript(sourceFile); let request: Request | undefined; @@ -436,7 +465,7 @@ namespace ts.Completions { } if (request) { - return { symbols: undefined, isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: false, location: undefined, isRightOfDot: false, request, keywordFilters: KeywordCompletionFilters.None }; + return { symbols: undefined, isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: false, location: undefined, isRightOfDot: false, request, keywordFilters: KeywordCompletionFilters.None, symbolToOriginInfoMap: undefined }; } if (!insideJsDocTagTypeExpression) { @@ -518,7 +547,6 @@ namespace ts.Completions { break; } // falls through - case SyntaxKind.JsxSelfClosingElement: case SyntaxKind.JsxElement: case SyntaxKind.JsxOpeningElement: @@ -537,6 +565,7 @@ namespace ts.Completions { let isNewIdentifierLocation: boolean; let keywordFilters = KeywordCompletionFilters.None; let symbols: Symbol[] = []; + const symbolToOriginInfoMap = createMap(); if (isRightOfDot) { getTypeScriptMemberSymbols(); @@ -573,7 +602,7 @@ namespace ts.Completions { log("getCompletionData: Semantic work: " + (timestamp() - semanticStart)); - return { symbols, isGlobalCompletion, isMemberCompletion, isNewIdentifierLocation, location, isRightOfDot: (isRightOfDot || isRightOfOpenTag), request, keywordFilters }; + return { symbols, isGlobalCompletion, isMemberCompletion, isNewIdentifierLocation, location, isRightOfDot: (isRightOfDot || isRightOfOpenTag), request, keywordFilters, symbolToOriginInfoMap }; type JSDocTagWithTypeExpression = JSDocAugmentsTag | JSDocParameterTag | JSDocPropertyTag | JSDocReturnTag | JSDocTypeTag | JSDocTypedefTag; @@ -747,7 +776,10 @@ namespace ts.Completions { } const symbolMeanings = SymbolFlags.Type | SymbolFlags.Value | SymbolFlags.Namespace | SymbolFlags.Alias; - symbols = filterGlobalCompletion(typeChecker.getSymbolsInScope(scopeNode, symbolMeanings)); + + symbols = typeChecker.getSymbolsInScope(scopeNode, symbolMeanings); + symbols.push(...getSymbolsFromOtherSourceFileExports(symbols, previousToken === undefined ? "" : previousToken.getText())); + symbols = filterGlobalCompletion(symbols); return true; } @@ -827,6 +859,37 @@ namespace ts.Completions { } } + function getSymbolsFromOtherSourceFileExports(knownSymbols: Symbol[], tokenText: string): Symbol[] { + const otherSourceFileExports: Symbol[] = []; + const tokenTextLowerCase = tokenText.toLowerCase(); + const symbolIdMap = arrayToMap(knownSymbols, s => getUniqueSymbolIdAsString(s, typeChecker)); + + const allPotentialModules = getOtherModuleSymbols(allSourceFiles, sourceFile, typeChecker); + for (const moduleSymbol of allPotentialModules) { + // check the default export + const defaultExport = typeChecker.tryGetMemberInModuleExports("default", moduleSymbol); + if (defaultExport) { + const localSymbol = getLocalSymbolForExportDefault(defaultExport); + if (localSymbol && !symbolIdMap.has(getUniqueSymbolIdAsString(localSymbol, typeChecker)) && startsWith(localSymbol.name.toLowerCase(), tokenTextLowerCase)) { + otherSourceFileExports.push(localSymbol); + symbolToOriginInfoMap.set(getUniqueSymbolIdAsString(localSymbol, typeChecker), { moduleSymbol, isDefaultExport: true }); + } + } + + // check exports with the same name + const allExportedSymbols = typeChecker.getExportsOfModule(moduleSymbol); + if (allExportedSymbols) { + for (const exportedSymbol of allExportedSymbols) { + if (exportedSymbol.name && !symbolIdMap.has(getUniqueSymbolIdAsString(exportedSymbol, typeChecker)) && startsWith(exportedSymbol.name.toLowerCase(), tokenTextLowerCase)) { + otherSourceFileExports.push(exportedSymbol); + symbolToOriginInfoMap.set(getUniqueSymbolIdAsString(exportedSymbol, typeChecker), { moduleSymbol }); + } + } + } + } + return otherSourceFileExports; + } + /** * Finds the first node that "embraces" the position, so that one may * accurately aggregate locals from the closest containing scope. diff --git a/src/services/services.ts b/src/services/services.ts index ed9732cc9cf5c..02a11a1c0ae15 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -1370,17 +1370,19 @@ namespace ts { function getCompletionsAtPosition(fileName: string, position: number): CompletionInfo { synchronizeHostData(); - return Completions.getCompletionsAtPosition(host, program.getTypeChecker(), log, program.getCompilerOptions(), getValidSourceFile(fileName), position); + return Completions.getCompletionsAtPosition(host, program.getTypeChecker(), log, program.getCompilerOptions(), getValidSourceFile(fileName), position, program.getSourceFiles()); } - function getCompletionEntryDetails(fileName: string, position: number, entryName: string): CompletionEntryDetails { + function getCompletionEntryDetails(fileName: string, position: number, entryName: string, formattingOptions?: FormatCodeSettings): CompletionEntryDetails { synchronizeHostData(); - return Completions.getCompletionEntryDetails(program.getTypeChecker(), log, program.getCompilerOptions(), getValidSourceFile(fileName), position, entryName); + const ruleProvider = formattingOptions ? getRuleProvider(formattingOptions) : undefined; + return Completions.getCompletionEntryDetails( + program.getTypeChecker(), log, program.getCompilerOptions(), getValidSourceFile(fileName), position, entryName, program.getSourceFiles(), host, ruleProvider); } function getCompletionEntrySymbol(fileName: string, position: number, entryName: string): Symbol { synchronizeHostData(); - return Completions.getCompletionEntrySymbol(program.getTypeChecker(), log, program.getCompilerOptions(), getValidSourceFile(fileName), position, entryName); + return Completions.getCompletionEntrySymbol(program.getTypeChecker(), log, program.getCompilerOptions(), getValidSourceFile(fileName), position, entryName, program.getSourceFiles()); } function getQuickInfoAtPosition(fileName: string, position: number): QuickInfo { diff --git a/src/services/types.ts b/src/services/types.ts index b034e2813c6e1..b84c8b89f0822 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -228,7 +228,7 @@ namespace ts { getEncodedSemanticClassifications(fileName: string, span: TextSpan): Classifications; getCompletionsAtPosition(fileName: string, position: number): CompletionInfo; - getCompletionEntryDetails(fileName: string, position: number, entryName: string): CompletionEntryDetails; + getCompletionEntryDetails(fileName: string, position: number, entryName: string, formattingOptions?: FormatCodeSettings): CompletionEntryDetails; getCompletionEntrySymbol(fileName: string, position: number, entryName: string): Symbol; getQuickInfoAtPosition(fileName: string, position: number): QuickInfo; @@ -664,6 +664,7 @@ namespace ts { * be used in that case */ replacementSpan?: TextSpan; + hasAction?: true; } export interface CompletionEntryDetails { @@ -673,6 +674,7 @@ namespace ts { displayParts: SymbolDisplayPart[]; documentation: SymbolDisplayPart[]; tags: JSDocTagInfo[]; + codeActions?: CodeAction[]; } export interface OutliningSpan { diff --git a/src/services/utilities.ts b/src/services/utilities.ts index 27215190db3fd..93f6cb368d5fb 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -1312,6 +1312,31 @@ namespace ts { return ensureScriptKind(fileName, host && host.getScriptKind && host.getScriptKind(fileName)); } + export function getOtherModuleSymbols( + sourceFiles: SourceFile[], + currentSourceFile: SourceFile, + typeChecker: TypeChecker + ) { + const results: Symbol[] = typeChecker.getAmbientModules(); + for (const otherSourceFile of sourceFiles) { + if (otherSourceFile !== currentSourceFile && isExternalOrCommonJsModule(otherSourceFile)) { + results.push(otherSourceFile.symbol); + } + } + return results; + } + + export function getUniqueSymbolIdAsString(symbol: Symbol, typeChecker: TypeChecker) { + return getUniqueSymbolId(symbol, typeChecker) + ""; + } + + export function getUniqueSymbolId(symbol: Symbol, typeChecker: TypeChecker) { + if (symbol.flags & SymbolFlags.Alias) { + return getSymbolId(typeChecker.getAliasedSymbol(symbol)); + } + return getSymbolId(symbol); + } + export function getFirstNonSpaceCharacterPosition(text: string, position: number) { while (isWhiteSpaceLike(text.charCodeAt(position))) { position += 1;