diff --git a/src/compiler/checker.ts b/src/compiler/checker.ts index 941803ea465b1..3ffacdb0abbb2 100644 --- a/src/compiler/checker.ts +++ b/src/compiler/checker.ts @@ -41414,7 +41414,7 @@ namespace ts { } else { Debug.assert(node.kind !== SyntaxKind.VariableDeclaration); - const importDeclaration = findAncestor(node, or(isImportDeclaration, isImportEqualsDeclaration)) as ImportDeclaration | ImportEqualsDeclaration | undefined; + const importDeclaration = findAncestor(node, or(isImportDeclaration, isImportEqualsDeclaration)); const moduleSpecifier = (importDeclaration && tryGetModuleSpecifierFromDeclaration(importDeclaration)?.text) ?? "..."; const importedIdentifier = unescapeLeadingUnderscores(isIdentifier(errorNode) ? errorNode.escapedText : symbol.escapedName); error( diff --git a/src/compiler/core.ts b/src/compiler/core.ts index 934f5ace84990..698f7bb2fb705 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -2371,6 +2371,9 @@ namespace ts { return (arg: T) => f(arg) && g(arg); } + export function or(f1: (p1: P) => p1 is R1, f2: (p2: P) => p2 is R2): (p: P) => p is R1 | R2; + export function or(f1: (p1: P) => p1 is R1, f2: (p2: P) => p2 is R2, f3: (p3: P) => p3 is R3): (p: P) => p is R1 | R2 | R3; + export function or(...fs: ((...args: T) => U)[]): (...args: T) => U; export function or(...fs: ((...args: T) => U)[]): (...args: T) => U { return (...args) => { let lastResult: U; diff --git a/src/services/completions.ts b/src/services/completions.ts index afea634c32b2c..bff90e2d3e7c5 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -492,7 +492,7 @@ namespace ts.Completions { isTypeOnlyLocation, isJsxIdentifierExpected, isRightOfOpenTag, - importCompletionNode, + importStatementCompletion, insideJsDocTagTypeExpression, symbolToSortTextMap: symbolToSortTextMap, hasUnresolvedAutoImports, @@ -530,7 +530,7 @@ namespace ts.Completions { propertyAccessToConvert, isJsxIdentifierExpected, isJsxInitializer, - importCompletionNode, + importStatementCompletion, recommendedCompletion, symbolToOriginInfoMap, symbolToSortTextMap, @@ -685,7 +685,7 @@ namespace ts.Completions { recommendedCompletion: Symbol | undefined, propertyAccessToConvert: PropertyAccessExpression | undefined, isJsxInitializer: IsJsxInitializer | undefined, - importCompletionNode: Node | undefined, + importStatementCompletion: ImportStatementCompletionInfo | undefined, useSemicolons: boolean, options: CompilerOptions, preferences: UserPreferences, @@ -751,8 +751,8 @@ namespace ts.Completions { if (originIsResolvedExport(origin)) { sourceDisplay = [textPart(origin.moduleSpecifier)]; - if (importCompletionNode) { - ({ insertText, replacementSpan } = getInsertTextAndReplacementSpanForImportCompletion(name, importCompletionNode, contextToken, origin, useSemicolons, options, preferences)); + if (importStatementCompletion) { + ({ insertText, replacementSpan } = getInsertTextAndReplacementSpanForImportCompletion(name, importStatementCompletion, origin, useSemicolons, sourceFile, options, preferences)); isSnippet = preferences.includeCompletionsWithSnippetText ? true : undefined; } } @@ -816,7 +816,7 @@ namespace ts.Completions { if (originIsExport(origin) || originIsResolvedExport(origin)) { data = originToCompletionEntryData(origin); - hasAction = !importCompletionNode; + hasAction = !importStatementCompletion; } // TODO(drosen): Right now we just permit *all* semantic meanings when calling @@ -841,7 +841,7 @@ namespace ts.Completions { labelDetails, isSnippet, isPackageJsonImport: originIsPackageJsonImport(origin) || undefined, - isImportStatementCompletion: !!importCompletionNode || undefined, + isImportStatementCompletion: !!importStatementCompletion || undefined, data, }; } @@ -1338,9 +1338,8 @@ namespace ts.Completions { return unresolvedOrigin; } - function getInsertTextAndReplacementSpanForImportCompletion(name: string, importCompletionNode: Node, contextToken: Node | undefined, origin: SymbolOriginInfoResolvedExport, useSemicolons: boolean, options: CompilerOptions, preferences: UserPreferences) { - const sourceFile = importCompletionNode.getSourceFile(); - const replacementSpan = createTextSpanFromNode(findAncestor(importCompletionNode, or(isImportDeclaration, isImportEqualsDeclaration)) || importCompletionNode, sourceFile); + function getInsertTextAndReplacementSpanForImportCompletion(name: string, importStatementCompletion: ImportStatementCompletionInfo, origin: SymbolOriginInfoResolvedExport, useSemicolons: boolean, sourceFile: SourceFile, options: CompilerOptions, preferences: UserPreferences) { + const replacementSpan = importStatementCompletion.replacementSpan; const quotedModuleSpecifier = quote(sourceFile, preferences, origin.moduleSpecifier); const exportKind = origin.isDefaultExport ? ExportKind.Default : @@ -1348,9 +1347,8 @@ namespace ts.Completions { ExportKind.Named; const tabStop = preferences.includeCompletionsWithSnippetText ? "$1" : ""; const importKind = codefix.getImportKind(sourceFile, exportKind, options, /*forceImportKeyword*/ true); - const isTopLevelTypeOnly = tryCast(importCompletionNode, isImportDeclaration)?.importClause?.isTypeOnly || tryCast(importCompletionNode, isImportEqualsDeclaration)?.isTypeOnly; - const isImportSpecifierTypeOnly = couldBeTypeOnlyImportSpecifier(importCompletionNode, contextToken); - const topLevelTypeOnlyText = isTopLevelTypeOnly ? ` ${tokenToString(SyntaxKind.TypeKeyword)} ` : " "; + const isImportSpecifierTypeOnly = importStatementCompletion.couldBeTypeOnlyImportSpecifier; + const topLevelTypeOnlyText = importStatementCompletion.isTopLevelTypeOnly ? ` ${tokenToString(SyntaxKind.TypeKeyword)} ` : " "; const importSpecifierTypeOnlyText = isImportSpecifierTypeOnly ? `${tokenToString(SyntaxKind.TypeKeyword)} ` : ""; const suffix = useSemicolons ? ";" : ""; switch (importKind) { @@ -1408,7 +1406,7 @@ namespace ts.Completions { propertyAccessToConvert?: PropertyAccessExpression, jsxIdentifierExpected?: boolean, isJsxInitializer?: IsJsxInitializer, - importCompletionNode?: Node, + importStatementCompletion?: ImportStatementCompletionInfo, recommendedCompletion?: Symbol, symbolToOriginInfoMap?: SymbolOriginInfoMap, symbolToSortTextMap?: SymbolSortTextMap, @@ -1450,7 +1448,7 @@ namespace ts.Completions { recommendedCompletion, propertyAccessToConvert, isJsxInitializer, - importCompletionNode, + importStatementCompletion, useSemicolons, compilerOptions, preferences, @@ -1725,7 +1723,7 @@ namespace ts.Completions { cancellationToken: CancellationToken, ): CodeActionsAndSourceDisplay { if (data?.moduleSpecifier) { - if (previousToken && getImportStatementCompletionInfo(contextToken || previousToken).replacementNode) { + if (previousToken && getImportStatementCompletionInfo(contextToken || previousToken).replacementSpan) { // Import statement completion: 'import c|' return { codeActions: undefined, sourceDisplay: [textPart(data.moduleSpecifier)] }; } @@ -1831,7 +1829,7 @@ namespace ts.Completions { /** In JSX tag name and attribute names, identifiers like "my-tag" or "aria-name" is valid identifier. */ readonly isJsxIdentifierExpected: boolean; readonly isRightOfOpenTag: boolean; - readonly importCompletionNode?: Node; + readonly importStatementCompletion?: ImportStatementCompletionInfo; readonly hasUnresolvedAutoImports?: boolean; readonly flags: CompletionInfoFlags; } @@ -2014,35 +2012,35 @@ namespace ts.Completions { let isStartingCloseTag = false; let isJsxInitializer: IsJsxInitializer = false; let isJsxIdentifierExpected = false; - let importCompletionNode: Node | undefined; + let importStatementCompletion: ImportStatementCompletionInfo | undefined; let location = getTouchingPropertyName(sourceFile, position); let keywordFilters = KeywordCompletionFilters.None; let isNewIdentifierLocation = false; let flags = CompletionInfoFlags.None; if (contextToken) { - const importStatementCompletion = getImportStatementCompletionInfo(contextToken); - isNewIdentifierLocation = importStatementCompletion.isNewIdentifierLocation; - if (importStatementCompletion.keywordCompletion) { - if (importStatementCompletion.isKeywordOnlyCompletion) { + const importStatementCompletionInfo = getImportStatementCompletionInfo(contextToken); + if (importStatementCompletionInfo.keywordCompletion) { + if (importStatementCompletionInfo.isKeywordOnlyCompletion) { return { kind: CompletionDataKind.Keywords, - keywordCompletions: [keywordToCompletionEntry(importStatementCompletion.keywordCompletion)], - isNewIdentifierLocation, + keywordCompletions: [keywordToCompletionEntry(importStatementCompletionInfo.keywordCompletion)], + isNewIdentifierLocation: importStatementCompletionInfo.isNewIdentifierLocation, }; } - keywordFilters = keywordFiltersFromSyntaxKind(importStatementCompletion.keywordCompletion); + keywordFilters = keywordFiltersFromSyntaxKind(importStatementCompletionInfo.keywordCompletion); } - if (importStatementCompletion.replacementNode && preferences.includeCompletionsForImportStatements && preferences.includeCompletionsWithInsertText) { + if (importStatementCompletionInfo.replacementSpan && preferences.includeCompletionsForImportStatements && preferences.includeCompletionsWithInsertText) { // Import statement completions use `insertText`, and also require the `data` property of `CompletionEntryIdentifier` // added in TypeScript 4.3 to be sent back from the client during `getCompletionEntryDetails`. Since this feature // is not backward compatible with older clients, the language service defaults to disabling it, allowing newer clients // to opt in with the `includeCompletionsForImportStatements` user preference. - importCompletionNode = importStatementCompletion.replacementNode; flags |= CompletionInfoFlags.IsImportStatementCompletion; + importStatementCompletion = importStatementCompletionInfo; + isNewIdentifierLocation = importStatementCompletionInfo.isNewIdentifierLocation; } // Bail out if this is a known invalid completion location - if (!importCompletionNode && isCompletionListBlocker(contextToken)) { + if (!importStatementCompletionInfo.replacementSpan && isCompletionListBlocker(contextToken)) { log("Returning an empty list because completion was requested in an invalid position."); return keywordFilters ? keywordCompletionData(keywordFilters, isJsOnlyLocation, isNewIdentifierDefinitionLocation()) @@ -2089,7 +2087,7 @@ namespace ts.Completions { return undefined; } } - else if (!importCompletionNode && sourceFile.languageVariant === LanguageVariant.JSX) { + else if (!importStatementCompletion) { // // If the tagname is a property access expression, we will then walk up to the top most of property access expression. // Then, try to get a JSX container and its associated attributes type. @@ -2246,7 +2244,7 @@ namespace ts.Completions { isTypeOnlyLocation, isJsxIdentifierExpected, isRightOfOpenTag, - importCompletionNode, + importStatementCompletion, hasUnresolvedAutoImports, flags, }; @@ -2522,7 +2520,7 @@ namespace ts.Completions { } function tryGetImportCompletionSymbols(): GlobalsSearch { - if (!importCompletionNode) return GlobalsSearch.Continue; + if (!importStatementCompletion) return GlobalsSearch.Continue; isNewIdentifierLocation = true; collectAutoImports(); return GlobalsSearch.Success; @@ -2611,7 +2609,7 @@ namespace ts.Completions { function shouldOfferImportCompletions(): boolean { // If already typing an import statement, provide completions for it. - if (importCompletionNode) return true; + if (importStatementCompletion) return true; // If current completion is for non-contextual Object literal shortahands, ignore auto-import symbols if (isNonContextualObjectLiteral) return false; // If not already a module, must have modules enabled. @@ -2638,7 +2636,7 @@ namespace ts.Completions { function isTypeOnlyCompletion(): boolean { return insideJsDocTagTypeExpression - || !!importCompletionNode && isTypeOnlyImportOrExportDeclaration(location.parent) + || !!importStatementCompletion && isTypeOnlyImportOrExportDeclaration(location.parent) || !isContextTokenValueLocation(contextToken) && (isPossiblyTypeArgumentPosition(contextToken, sourceFile, typeChecker) || isPartOfTypeNode(location) @@ -2695,8 +2693,7 @@ namespace ts.Completions { flags |= CompletionInfoFlags.MayIncludeAutoImports; // import { type | -> token text should be blank const isAfterTypeOnlyImportSpecifierModifier = previousToken === contextToken - && importCompletionNode - && couldBeTypeOnlyImportSpecifier(importCompletionNode, contextToken); + && importStatementCompletion; const lowerCaseTokenText = isAfterTypeOnlyImportSpecifierModifier ? "" : @@ -2714,7 +2711,7 @@ namespace ts.Completions { program, position, preferences, - !!importCompletionNode, + !!importStatementCompletion, isValidTypeOnlyAliasUseSite(location), context => { exportInfo.search( @@ -2723,7 +2720,7 @@ namespace ts.Completions { (symbolName, targetFlags) => { if (!isIdentifierText(symbolName, getEmitScriptTarget(host.getCompilationSettings()))) return false; if (!detailsEntryId && isStringANonContextualKeyword(symbolName)) return false; - if (!isTypeOnlyLocation && !importCompletionNode && !(targetFlags & SymbolFlags.Value)) return false; + if (!isTypeOnlyLocation && !importStatementCompletion && !(targetFlags & SymbolFlags.Value)) return false; if (isTypeOnlyLocation && !(targetFlags & (SymbolFlags.Module | SymbolFlags.Type))) return false; // Do not try to auto-import something with a lowercase first letter for a JSX tag const firstChar = symbolName.charCodeAt(0); @@ -2817,7 +2814,7 @@ namespace ts.Completions { return; } symbolToOriginInfoMap[symbols.length] = origin; - symbolToSortTextMap[symbolId] = importCompletionNode ? SortText.LocationPriority : SortText.AutoImportSuggestions; + symbolToSortTextMap[symbolId] = importStatementCompletion ? SortText.LocationPriority : SortText.AutoImportSuggestions; symbols.push(symbol); } @@ -4248,7 +4245,9 @@ namespace ts.Completions { isKeywordOnlyCompletion: boolean; keywordCompletion: TokenSyntaxKind | undefined; isNewIdentifierLocation: boolean; - replacementNode: ImportEqualsDeclaration | ImportDeclaration | ImportSpecifier | Token | undefined; + isTopLevelTypeOnly: boolean; + couldBeTypeOnlyImportSpecifier: boolean; + replacementSpan: TextSpan | undefined; } function getImportStatementCompletionInfo(contextToken: Node): ImportStatementCompletionInfo { @@ -4259,9 +4258,9 @@ namespace ts.Completions { isKeywordOnlyCompletion, keywordCompletion, isNewIdentifierLocation: !!(candidate || keywordCompletion === SyntaxKind.TypeKeyword), - replacementNode: candidate && rangeIsOnSingleLine(candidate, candidate.getSourceFile()) - ? candidate - : undefined + isTopLevelTypeOnly: !!tryCast(candidate, isImportDeclaration)?.importClause?.isTypeOnly || !!tryCast(candidate, isImportEqualsDeclaration)?.isTypeOnly, + couldBeTypeOnlyImportSpecifier: !!candidate && couldBeTypeOnlyImportSpecifier(candidate, contextToken), + replacementSpan: getSingleLineReplacementSpanForImportCompletionNode(candidate), }; function getCandidate() { @@ -4308,15 +4307,70 @@ namespace ts.Completions { } } + function getSingleLineReplacementSpanForImportCompletionNode(node: ImportDeclaration | ImportEqualsDeclaration | ImportSpecifier | Token | undefined) { + if (!node) return undefined; + const top = findAncestor(node, or(isImportDeclaration, isImportEqualsDeclaration)) ?? node; + const sourceFile = top.getSourceFile(); + if (rangeIsOnSingleLine(top, sourceFile)) { + return createTextSpanFromNode(top, sourceFile); + } + // ImportKeyword was necessarily on one line; ImportSpecifier was necessarily parented in an ImportDeclaration + Debug.assert(top.kind !== SyntaxKind.ImportKeyword && top.kind !== SyntaxKind.ImportSpecifier); + // Guess which point in the import might actually be a later statement parsed as part of the import + // during parser recovery - either in the middle of named imports, or the module specifier. + const potentialSplitPoint = top.kind === SyntaxKind.ImportDeclaration + ? getPotentiallyInvalidImportSpecifier(top.importClause?.namedBindings) ?? top.moduleSpecifier + : top.moduleReference; + const withoutModuleSpecifier: TextRange = { + pos: top.getFirstToken()!.getStart(), + end: potentialSplitPoint.pos, + }; + // The module specifier/reference was previously found to be missing, empty, or + // not a string literal - in this last case, it's likely that statement on a following + // line was parsed as the module specifier of a partially-typed import, e.g. + // import Foo| + // interface Blah {} + // This appears to be a multiline-import, and editors can't replace multiple lines. + // But if everything but the "module specifier" is on one line, by this point we can + // assume that the "module specifier" is actually just another statement, and return + // the single-line range of the import excluding that probable statement. + if (rangeIsOnSingleLine(withoutModuleSpecifier, sourceFile)) { + return createTextSpanFromRange(withoutModuleSpecifier); + } + } + + // Tries to identify the first named import that is not really a named import, but rather + // just parser recovery for a situation like: + // import { Foo| + // interface Bar {} + // in which `Foo`, `interface`, and `Bar` are all parsed as import specifiers. The caller + // will also check if this token is on a separate line from the rest of the import. + function getPotentiallyInvalidImportSpecifier(namedBindings: NamedImportBindings | undefined) { + return find( + tryCast(namedBindings, isNamedImports)?.elements, + e => !e.propertyName && + isStringANonContextualKeyword(e.name.text) && + findPrecedingToken(e.name.pos, namedBindings!.getSourceFile(), namedBindings)?.kind !== SyntaxKind.CommaToken); + } + function couldBeTypeOnlyImportSpecifier(importSpecifier: Node, contextToken: Node | undefined): importSpecifier is ImportSpecifier { return isImportSpecifier(importSpecifier) && (importSpecifier.isTypeOnly || contextToken === importSpecifier.name && isTypeKeywordTokenOrIdentifier(contextToken)); } function canCompleteFromNamedBindings(namedBindings: NamedImportBindings) { - return isModuleSpecifierMissingOrEmpty(namedBindings.parent.parent.moduleSpecifier) - && (isNamespaceImport(namedBindings) || namedBindings.elements.length < 2) - && !namedBindings.parent.name; + if (!isModuleSpecifierMissingOrEmpty(namedBindings.parent.parent.moduleSpecifier) || namedBindings.parent.name) { + return false; + } + if (isNamedImports(namedBindings)) { + // We can only complete on named imports if there are no other named imports already, + // but parser recovery sometimes puts later statements in the named imports list, so + // we try to only consider the probably-valid ones. + const invalidNamedImport = getPotentiallyInvalidImportSpecifier(namedBindings); + const validImports = invalidNamedImport ? namedBindings.elements.indexOf(invalidNamedImport) : namedBindings.elements.length; + return validImports < 2; + } + return true; } function isModuleSpecifierMissingOrEmpty(specifier: ModuleReference | Expression) { diff --git a/tests/cases/fourslash/importStatementCompletions1.ts b/tests/cases/fourslash/importStatementCompletions1.ts index 89bc68189f9ee..0e970254346a5 100644 --- a/tests/cases/fourslash/importStatementCompletions1.ts +++ b/tests/cases/fourslash/importStatementCompletions1.ts @@ -22,37 +22,37 @@ // @Filename: /index5.ts //// import f/*5*/ from ""; -// ([[0, true], [1, true], [2, false], [3, true], [4, true], [5, true]] as const).forEach(([marker, typeKeywordValid]) => { -// verify.completions({ -// isNewIdentifierLocation: true, -// marker: "" + marker, -// exact: [{ -// name: "foo", -// source: "./mod", -// insertText: `import { foo$1 } from "./mod";`, -// isSnippet: true, -// replacementSpan: test.ranges()[marker], -// sourceDisplay: "./mod", -// }, -// { -// name: "Foo", -// source: "./mod", -// insertText: `import { Foo$1 } from "./mod";`, -// isSnippet: true, -// replacementSpan: test.ranges()[marker], -// sourceDisplay: "./mod", -// }, -// ...typeKeywordValid ? [{ -// name: "type", -// sortText: completion.SortText.GlobalsOrKeywords, -// }] : []], -// preferences: { -// includeCompletionsForImportStatements: true, -// includeInsertTextCompletions: true, -// includeCompletionsWithSnippetText: true, -// } -// }); -// }); +([[0, true], [1, true], [2, false], [3, true], [4, true], [5, true]] as const).forEach(([marker, typeKeywordValid]) => { + verify.completions({ + isNewIdentifierLocation: true, + marker: "" + marker, + exact: [{ + name: "foo", + source: "./mod", + insertText: `import { foo$1 } from "./mod";`, + isSnippet: true, + replacementSpan: test.ranges()[marker], + sourceDisplay: "./mod", + }, + { + name: "Foo", + source: "./mod", + insertText: `import { Foo$1 } from "./mod";`, + isSnippet: true, + replacementSpan: test.ranges()[marker], + sourceDisplay: "./mod", + }, + ...typeKeywordValid ? [{ + name: "type", + sortText: completion.SortText.GlobalsOrKeywords, + }] : []], + preferences: { + includeCompletionsForImportStatements: true, + includeInsertTextCompletions: true, + includeCompletionsWithSnippetText: true, + } + }); +}); // @Filename: /index6.ts //// import f/*6*/ from "nope"; diff --git a/tests/cases/fourslash/importStatementCompletions2.ts b/tests/cases/fourslash/importStatementCompletions2.ts new file mode 100644 index 0000000000000..05e668263c3c9 --- /dev/null +++ b/tests/cases/fourslash/importStatementCompletions2.ts @@ -0,0 +1,46 @@ +/// + +// @Filename: /button.ts +//// export function Button() {} + +// @Filename: /index1.ts +//// [|import Butt/*0*/|] +//// +//// interface Props {} +//// const x = 0 + +// @Filename: /index2.ts +//// [|import { Butt/*1*/ }|] +//// +//// interface Props {} +//// const x = 0 + +// @Filename: /index3.ts +//// [|import { Butt/*2*/|] +//// +//// interface Props {} +//// const x = 0 + +[0, 1, 2].forEach(marker => { + verify.completions({ + marker: `${marker}`, + isNewIdentifierLocation: true, + exact: [{ + name: "Button", + source: "./button", + sourceDisplay: "./button", + insertText: `import { Button$1 } from "./button"`, + isSnippet: true, + replacementSpan: test.ranges()[marker], + }, { + name: "type", + sortText: completion.SortText.GlobalsOrKeywords, + }], + preferences: { + includeCompletionsForImportStatements: true, + includeCompletionsForModuleExports: true, + includeCompletionsWithSnippetText: true, + includeCompletionsWithInsertText: true, + }, + }); +});