diff --git a/src/compiler/checker.ts b/src/compiler/checker.ts index 2a670d4124477..1eb9e3568898d 100644 --- a/src/compiler/checker.ts +++ b/src/compiler/checker.ts @@ -3498,7 +3498,10 @@ namespace ts { const exports = getExportsOfModuleAsArray(moduleSymbol); const exportEquals = resolveExternalModuleSymbol(moduleSymbol); if (exportEquals !== moduleSymbol) { - addRange(exports, getPropertiesOfType(getTypeOfSymbol(exportEquals))); + const type = getTypeOfSymbol(exportEquals); + if (shouldTreatPropertiesOfExternalModuleAsExports(type)) { + addRange(exports, getPropertiesOfType(type)); + } } return exports; } @@ -3522,11 +3525,15 @@ namespace ts { } const type = getTypeOfSymbol(exportEquals); - return type.flags & TypeFlags.Primitive || - getObjectFlags(type) & ObjectFlags.Class || - isArrayOrTupleLikeType(type) - ? undefined - : getPropertyOfType(type, memberName); + return shouldTreatPropertiesOfExternalModuleAsExports(type) ? getPropertyOfType(type, memberName) : undefined; + } + + function shouldTreatPropertiesOfExternalModuleAsExports(resolvedExternalModuleType: Type) { + return !(resolvedExternalModuleType.flags & TypeFlags.Primitive || + getObjectFlags(resolvedExternalModuleType) & ObjectFlags.Class || + // `isArrayOrTupleLikeType` is too expensive to use in this auto-imports hot path + isArrayType(resolvedExternalModuleType) || + isTupleType(resolvedExternalModuleType)); } function getExportsOfSymbol(symbol: Symbol): SymbolTable { diff --git a/src/compiler/moduleSpecifiers.ts b/src/compiler/moduleSpecifiers.ts index 3fcda6d428db8..ac30c5328b082 100644 --- a/src/compiler/moduleSpecifiers.ts +++ b/src/compiler/moduleSpecifiers.ts @@ -327,19 +327,17 @@ namespace ts.moduleSpecifiers { : undefined); } - interface ModulePath { - path: string; - isInNodeModules: boolean; - isRedirect: boolean; - } - /** * Looks for existing imports that use symlinks to this module. * Symlinks will be returned first so they are preferred over the real path. */ - function getAllModulePaths(importingFileName: string, importedFileName: string, host: ModuleSpecifierResolutionHost): readonly ModulePath[] { - const cwd = host.getCurrentDirectory(); + function getAllModulePaths(importingFileName: Path, importedFileName: string, host: ModuleSpecifierResolutionHost): readonly ModulePath[] { + const cache = host.getModuleSpecifierCache?.(); const getCanonicalFileName = hostGetCanonicalFileName(host); + if (cache) { + const cached = cache.get(importingFileName, toPath(importedFileName, host.getCurrentDirectory(), getCanonicalFileName)); + if (typeof cached === "object") return cached; + } const allFileNames = new Map(); let importedFileFromNodeModules = false; forEachFileNameOfModule( @@ -358,7 +356,7 @@ namespace ts.moduleSpecifiers { // Sort by paths closest to importing file Name directory const sortedPaths: ModulePath[] = []; for ( - let directory = getDirectoryPath(toPath(importingFileName, cwd, getCanonicalFileName)); + let directory = getDirectoryPath(importingFileName); allFileNames.size !== 0; ) { const directoryStart = ensureTrailingDirectorySeparator(directory); @@ -384,6 +382,10 @@ namespace ts.moduleSpecifiers { if (remainingPaths.length > 1) remainingPaths.sort(comparePathsByRedirectAndNumberOfDirectorySeparators); sortedPaths.push(...remainingPaths); } + + if (cache) { + cache.set(importingFileName, toPath(importedFileName, host.getCurrentDirectory(), getCanonicalFileName), sortedPaths); + } return sortedPaths; } diff --git a/src/compiler/types.ts b/src/compiler/types.ts index 081386e8b7ff6..e86dd4cb7cc98 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -8015,6 +8015,7 @@ namespace ts { readFile?(path: string): string | undefined; realpath?(path: string): string; getSymlinkCache?(): SymlinkCache; + getModuleSpecifierCache?(): ModuleSpecifierCache; getGlobalTypingsCacheLocation?(): string | undefined; getNearestAncestorDirectoryWithPackageJson?(fileName: string, rootDir?: string): string | undefined; @@ -8025,6 +8026,21 @@ namespace ts { getFileIncludeReasons(): MultiMap; } + /* @internal */ + export interface ModulePath { + path: string; + isInNodeModules: boolean; + isRedirect: boolean; + } + + /* @internal */ + export interface ModuleSpecifierCache { + get(fromFileName: Path, toFileName: Path): boolean | readonly ModulePath[] | undefined; + set(fromFileName: Path, toFileName: Path, moduleSpecifiers: boolean | readonly ModulePath[]): void; + clear(): void; + count(): number; + } + // Note: this used to be deprecated in our public API, but is still used internally /* @internal */ export interface SymbolTracker { @@ -8314,6 +8330,8 @@ namespace ts { readonly disableSuggestions?: boolean; readonly quotePreference?: "auto" | "double" | "single"; readonly includeCompletionsForModuleExports?: boolean; + readonly includeCompletionsForImportStatements?: boolean; + readonly includeCompletionsWithSnippetText?: boolean; readonly includeAutomaticOptionalChainCompletions?: boolean; readonly includeCompletionsWithInsertText?: boolean; readonly importModuleSpecifierPreference?: "shortest" | "project-relative" | "relative" | "non-relative"; diff --git a/src/compiler/utilities.ts b/src/compiler/utilities.ts index 2fe2728a25213..1dd73655f2774 100644 --- a/src/compiler/utilities.ts +++ b/src/compiler/utilities.ts @@ -2412,6 +2412,19 @@ namespace ts { return decl.kind === SyntaxKind.FunctionDeclaration || isVariableDeclaration(decl) && decl.initializer && isFunctionLike(decl.initializer); } + export function tryGetModuleSpecifierFromDeclaration(node: AnyImportOrRequire): string | undefined { + switch (node.kind) { + case SyntaxKind.VariableDeclaration: + return node.initializer.arguments[0].text; + case SyntaxKind.ImportDeclaration: + return tryCast(node.moduleSpecifier, isStringLiteralLike)?.text; + case SyntaxKind.ImportEqualsDeclaration: + return tryCast(tryCast(node.moduleReference, isExternalModuleReference)?.expression, isStringLiteralLike)?.text; + default: + Debug.assertNever(node); + } + } + export function importFromModuleSpecifier(node: StringLiteralLike): AnyValidImportOrReExport { return tryGetImportFromModuleSpecifier(node) || Debug.failBadSyntaxKind(node.parent); } diff --git a/src/harness/fourslashImpl.ts b/src/harness/fourslashImpl.ts index dc50e21ac894f..a39e4525fd617 100644 --- a/src/harness/fourslashImpl.ts +++ b/src/harness/fourslashImpl.ts @@ -930,8 +930,12 @@ namespace FourSlash { assert.equal(actual.hasAction, expected.hasAction, `Expected 'hasAction' properties to match`); assert.equal(actual.isRecommended, expected.isRecommended, `Expected 'isRecommended' properties to match'`); + assert.equal(actual.isSnippet, expected.isSnippet, `Expected 'isSnippet' properties to match`); assert.equal(actual.source, expected.source, `Expected 'source' values to match`); - assert.equal(actual.sortText, expected.sortText || ts.Completions.SortText.LocationPriority, this.messageAtLastKnownMarker(`Actual entry: ${JSON.stringify(actual)}`)); + assert.equal(actual.sortText, expected.sortText || ts.Completions.SortText.LocationPriority, `Expected 'sortText' properties to match`); + if (expected.sourceDisplay && actual.sourceDisplay) { + assert.equal(ts.displayPartsToString(actual.sourceDisplay), expected.sourceDisplay, `Expected 'sourceDisplay' properties to match`); + } if (expected.text !== undefined) { const actualDetails = ts.Debug.checkDefined(this.getCompletionEntryDetails(actual.name, actual.source, actual.data), `No completion details available for name '${actual.name}' and source '${actual.source}'`); @@ -941,10 +945,13 @@ namespace FourSlash { // assert.equal(actualDetails.kind, actual.kind); assert.equal(actualDetails.kindModifiers, actual.kindModifiers, "Expected 'kindModifiers' properties to match"); assert.equal(actualDetails.source && ts.displayPartsToString(actualDetails.source), expected.sourceDisplay, "Expected 'sourceDisplay' property to match 'source' display parts string"); + if (!actual.sourceDisplay) { + assert.equal(actualDetails.sourceDisplay && ts.displayPartsToString(actualDetails.sourceDisplay), expected.sourceDisplay, "Expected 'sourceDisplay' property to match 'sourceDisplay' display parts string"); + } assert.deepEqual(actualDetails.tags, expected.tags); } else { - assert(expected.documentation === undefined && expected.tags === undefined && expected.sourceDisplay === undefined, "If specifying completion details, should specify 'text'"); + assert(expected.documentation === undefined && expected.tags === undefined, "If specifying completion details, should specify 'text'"); } } diff --git a/src/harness/fourslashInterfaceImpl.ts b/src/harness/fourslashInterfaceImpl.ts index 67da4ae6fe3cd..d50625b52a4c6 100644 --- a/src/harness/fourslashInterfaceImpl.ts +++ b/src/harness/fourslashInterfaceImpl.ts @@ -1599,6 +1599,7 @@ namespace FourSlashInterface { readonly isFromUncheckedFile?: boolean; // If not specified, won't assert about this readonly kind?: string; // If not specified, won't assert about this readonly isPackageJsonImport?: boolean; // If not specified, won't assert about this + readonly isSnippet?: boolean; readonly kindModifiers?: string; // Must be paired with 'kind' readonly text?: string; readonly documentation?: string; diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index db7a90aace690..f012491731867 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -2958,7 +2958,7 @@ namespace ts.server { }); } if (includePackageJsonAutoImports !== args.preferences.includePackageJsonAutoImports) { - this.invalidateProjectAutoImports(/*packageJsonPath*/ undefined); + this.invalidateProjectPackageJson(/*packageJsonPath*/ undefined); } } if (args.extraFileExtensions) { @@ -4041,7 +4041,7 @@ namespace ts.server { private watchPackageJsonFile(path: Path) { const watchers = this.packageJsonFilesMap || (this.packageJsonFilesMap = new Map()); if (!watchers.has(path)) { - this.invalidateProjectAutoImports(path); + this.invalidateProjectPackageJson(path); watchers.set(path, this.watchFactory.watchFile( path, (fileName, eventKind) => { @@ -4051,11 +4051,11 @@ namespace ts.server { return Debug.fail(); case FileWatcherEventKind.Changed: this.packageJsonCache.addOrUpdate(path); - this.invalidateProjectAutoImports(path); + this.invalidateProjectPackageJson(path); break; case FileWatcherEventKind.Deleted: this.packageJsonCache.delete(path); - this.invalidateProjectAutoImports(path); + this.invalidateProjectPackageJson(path); watchers.get(path)!.close(); watchers.delete(path); } @@ -4083,15 +4083,16 @@ namespace ts.server { } /*@internal*/ - private invalidateProjectAutoImports(packageJsonPath: Path | undefined) { - if (this.includePackageJsonAutoImports()) { - this.configuredProjects.forEach(invalidate); - this.inferredProjects.forEach(invalidate); - this.externalProjects.forEach(invalidate); - } + private invalidateProjectPackageJson(packageJsonPath: Path | undefined) { + this.configuredProjects.forEach(invalidate); + this.inferredProjects.forEach(invalidate); + this.externalProjects.forEach(invalidate); function invalidate(project: Project) { - if (!packageJsonPath || project.packageJsonsForAutoImport?.has(packageJsonPath)) { - project.markAutoImportProviderAsDirty(); + if (packageJsonPath) { + project.onPackageJsonChange(packageJsonPath); + } + else { + project.onAutoImportProviderSettingsChanged(); } } } diff --git a/src/server/project.ts b/src/server/project.ts index a427c14cfa44a..1d1bcd1e7f9c4 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -146,6 +146,8 @@ namespace ts.server { lastCachedUnresolvedImportsList: SortedReadonlyArray | undefined; /*@internal*/ private hasAddedorRemovedFiles = false; + /*@internal*/ + private hasAddedOrRemovedSymlinks = false; /*@internal*/ lastFileExceededProgramSize: string | undefined; @@ -204,7 +206,7 @@ namespace ts.server { originalConfiguredProjects: Set | undefined; /*@internal*/ - packageJsonsForAutoImport: Set | undefined; + private packageJsonsForAutoImport: Set | undefined; /*@internal*/ getResolvedProjectReferenceToRedirect(_fileName: string): ResolvedProjectReference | undefined { @@ -248,9 +250,11 @@ namespace ts.server { public readonly getCanonicalFileName: GetCanonicalFileName; /*@internal*/ - private importSuggestionsCache = Completions.createImportSuggestionsForFileCache(); + private exportMapCache = createExportMapCache(); + /*@internal*/ + private changedFilesForExportMapCache: Set | undefined; /*@internal*/ - private dirtyFilesForSuggestions: Set | undefined; + private moduleSpecifierCache = createModuleSpecifierCache(); /*@internal*/ private symlinks: SymlinkCache | undefined; /*@internal*/ @@ -979,8 +983,8 @@ namespace ts.server { /*@internal*/ markFileAsDirty(changedFile: Path) { this.markAsDirty(); - if (!this.importSuggestionsCache.isEmpty()) { - (this.dirtyFilesForSuggestions || (this.dirtyFilesForSuggestions = new Set())).add(changedFile); + if (!this.exportMapCache.isEmpty()) { + (this.changedFilesForExportMapCache ||= new Set()).add(changedFile); } } @@ -992,17 +996,31 @@ namespace ts.server { } /*@internal*/ - markAutoImportProviderAsDirty() { + onAutoImportProviderSettingsChanged() { if (this.autoImportProviderHost === false) { this.autoImportProviderHost = undefined; } - this.autoImportProviderHost?.markAsDirty(); - this.importSuggestionsCache.clear(); + else { + this.autoImportProviderHost?.markAsDirty(); + } + } + + /*@internal*/ + onPackageJsonChange(packageJsonPath: Path) { + if (this.packageJsonsForAutoImport?.has(packageJsonPath)) { + this.moduleSpecifierCache.clear(); + if (this.autoImportProviderHost) { + this.autoImportProviderHost.markAsDirty(); + } + } } /* @internal */ - onFileAddedOrRemoved() { + onFileAddedOrRemoved(isSymlink: boolean | undefined) { this.hasAddedorRemovedFiles = true; + if (isSymlink) { + this.hasAddedOrRemovedSymlinks = true; + } } /** @@ -1016,6 +1034,7 @@ namespace ts.server { const hasNewProgram = this.updateGraphWorker(); const hasAddedorRemovedFiles = this.hasAddedorRemovedFiles; this.hasAddedorRemovedFiles = false; + this.hasAddedOrRemovedSymlinks = false; const changedFiles: readonly Path[] = this.resolutionCache.finishRecordingFilesWithChangedResolutions() || emptyArray; @@ -1166,27 +1185,30 @@ namespace ts.server { } } - if (!this.importSuggestionsCache.isEmpty()) { + if (!this.exportMapCache.isEmpty()) { if (this.hasAddedorRemovedFiles || oldProgram && !this.program!.structureIsReused) { - this.importSuggestionsCache.clear(); + this.exportMapCache.clear(); } - else if (this.dirtyFilesForSuggestions && oldProgram && this.program) { - forEachKey(this.dirtyFilesForSuggestions, fileName => { - const oldSourceFile = oldProgram.getSourceFile(fileName); - const sourceFile = this.program!.getSourceFile(fileName); - if (this.sourceFileHasChangedOwnImportSuggestions(oldSourceFile, sourceFile)) { - this.importSuggestionsCache.clear(); + else if (this.changedFilesForExportMapCache && oldProgram && this.program) { + forEachKey(this.changedFilesForExportMapCache, fileName => { + const oldSourceFile = oldProgram.getSourceFileByPath(fileName); + const sourceFile = this.program!.getSourceFileByPath(fileName); + if (!oldSourceFile || !sourceFile) { + this.exportMapCache.clear(); return true; } + return this.exportMapCache.onFileChanged(oldSourceFile, sourceFile, !!this.getTypeAcquisition().enable); }); } } - if (this.dirtyFilesForSuggestions) { - this.dirtyFilesForSuggestions.clear(); + if (this.changedFilesForExportMapCache) { + this.changedFilesForExportMapCache.clear(); } - if (this.hasAddedorRemovedFiles) { + if (this.hasAddedOrRemovedSymlinks || this.program && !this.program.structureIsReused && this.getCompilerOptions().preserveSymlinks) { + // With --preserveSymlinks, we may not determine that a file is a symlink, so we never set `hasAddedOrRemovedSymlinks` this.symlinks = undefined; + this.moduleSpecifierCache.clear(); } const oldExternalFiles = this.externalFiles || emptyArray as SortedReadonlyArray; @@ -1218,54 +1240,6 @@ namespace ts.server { this.projectService.sendPerformanceEvent(kind, durationMs); } - /*@internal*/ - private sourceFileHasChangedOwnImportSuggestions(oldSourceFile: SourceFile | undefined, newSourceFile: SourceFile | undefined) { - if (!oldSourceFile && !newSourceFile) { - return false; - } - // Probably shouldn’t get this far, but on the off chance the file was added or removed, - // we can’t reliably tell anything about it. - if (!oldSourceFile || !newSourceFile) { - return true; - } - - Debug.assertEqual(oldSourceFile.fileName, newSourceFile.fileName); - // If ATA is enabled, auto-imports uses existing imports to guess whether you want auto-imports from node. - // Adding or removing imports from node could change the outcome of that guess, so could change the suggestions list. - if (this.getTypeAcquisition().enable && consumesNodeCoreModules(oldSourceFile) !== consumesNodeCoreModules(newSourceFile)) { - return true; - } - - // Module agumentation and ambient module changes can add or remove exports available to be auto-imported. - // Changes elsewhere in the file can change the *type* of an export in a module augmentation, - // but type info is gathered in getCompletionEntryDetails, which doesn’t use the cache. - if ( - !arrayIsEqualTo(oldSourceFile.moduleAugmentations, newSourceFile.moduleAugmentations) || - !this.ambientModuleDeclarationsAreEqual(oldSourceFile, newSourceFile) - ) { - return true; - } - return false; - } - - /*@internal*/ - private ambientModuleDeclarationsAreEqual(oldSourceFile: SourceFile, newSourceFile: SourceFile) { - if (!arrayIsEqualTo(oldSourceFile.ambientModuleNames, newSourceFile.ambientModuleNames)) { - return false; - } - let oldFileStatementIndex = -1; - let newFileStatementIndex = -1; - for (const ambientModuleName of newSourceFile.ambientModuleNames) { - const isMatchingModuleDeclaration = (node: Statement) => isNonGlobalAmbientModule(node) && node.name.text === ambientModuleName; - oldFileStatementIndex = findIndex(oldSourceFile.statements, isMatchingModuleDeclaration, oldFileStatementIndex + 1); - newFileStatementIndex = findIndex(newSourceFile.statements, isMatchingModuleDeclaration, newFileStatementIndex + 1); - if (oldSourceFile.statements[oldFileStatementIndex] !== newSourceFile.statements[newFileStatementIndex]) { - return false; - } - } - return true; - } - private detachScriptInfoFromProject(uncheckedFileName: string, noRemoveResolution?: boolean) { const scriptInfoToDetach = this.projectService.getScriptInfo(uncheckedFileName); if (scriptInfoToDetach) { @@ -1683,8 +1657,13 @@ namespace ts.server { } /*@internal*/ - getImportSuggestionsCache() { - return this.importSuggestionsCache; + getExportMapCache() { + return this.exportMapCache; + } + + /*@internal*/ + getModuleSpecifierCache() { + return this.moduleSpecifierCache; } /*@internal*/ @@ -2016,8 +1995,12 @@ namespace ts.server { this.projectService.setFileNamesOfAutoImportProviderProject(this, rootFileNames); this.rootFileNames = rootFileNames; - this.hostProject.getImportSuggestionsCache().clear(); - return super.updateGraph(); + const oldProgram = this.getCurrentProgram(); + const hasSameSetOfFiles = super.updateGraph(); + if (oldProgram && oldProgram !== this.getCurrentProgram()) { + this.hostProject.getExportMapCache().clear(); + } + return hasSameSetOfFiles; } hasRoots() { @@ -2037,10 +2020,16 @@ namespace ts.server { throw new Error("AutoImportProviderProject language service should never be used. To get the program, use `project.getCurrentProgram()`."); } - markAutoImportProviderAsDirty(): never { + /*@internal*/ + onAutoImportProviderSettingsChanged(): never { throw new Error("AutoImportProviderProject is an auto import provider; use `markAsDirty()` instead."); } + /*@internal*/ + onPackageJsonChange(): never { + throw new Error("package.json changes should be notified on an AutoImportProvider's host project"); + } + getModuleResolutionHostForAutoImportProvider(): never { throw new Error("AutoImportProviderProject cannot provide its own host; use `hostProject.getModuleResolutionHostForAutomImportProvider()` instead."); } diff --git a/src/server/protocol.ts b/src/server/protocol.ts index dc29f341ae30f..ffcde781d85c5 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -2142,7 +2142,7 @@ namespace ts.server.protocol { arguments: FormatOnKeyRequestArgs; } - export type CompletionsTriggerCharacter = "." | '"' | "'" | "`" | "/" | "@" | "<" | "#"; + export type CompletionsTriggerCharacter = "." | '"' | "'" | "`" | "/" | "@" | "<" | "#" | " "; /** * Arguments for completions messages. @@ -2253,6 +2253,10 @@ namespace ts.server.protocol { * coupled with `replacementSpan` to replace a dotted access with a bracket access. */ insertText?: string; + /** + * `insertText` should be interpreted as a snippet if true. + */ + isSnippet?: true; /** * An optional span that indicates the text to be replaced by this completion item. * If present, this span should be used instead of the default one. @@ -2268,6 +2272,10 @@ namespace ts.server.protocol { * Identifier (not necessarily human-readable) identifying where this completion came from. */ source?: string; + /** + * Human-readable description of the `source`. + */ + sourceDisplay?: SymbolDisplayPart[]; /** * If true, this completion should be highlighted as recommended. There will only be one of these. * This will be set when we know the user should write an expression with a certain type and that type is an enum or constructable class. @@ -2329,9 +2337,14 @@ namespace ts.server.protocol { codeActions?: CodeAction[]; /** - * Human-readable description of the `source` from the CompletionEntry. + * @deprecated Use `sourceDisplay` instead. */ source?: SymbolDisplayPart[]; + + /** + * Human-readable description of the `source` from the CompletionEntry. + */ + sourceDisplay?: SymbolDisplayPart[]; } /** @deprecated Prefer CompletionInfoResponse, which supports several top-level fields in addition to the array of entries. */ @@ -2353,6 +2366,7 @@ namespace ts.server.protocol { * must be used to commit that completion entry. */ readonly optionalReplacementSpan?: TextSpan; + readonly isIncomplete?: boolean; readonly entries: readonly CompletionEntry[]; } @@ -3298,6 +3312,15 @@ namespace ts.server.protocol { * This affects lone identifier completions but not completions on the right hand side of `obj.`. */ readonly includeCompletionsForModuleExports?: boolean; + /** + * Enables auto-import-style completions on partially-typed import statements. E.g., allows + * `import write|` to be completed to `import { writeFile } from "fs"`. + */ + readonly includeCompletionsForImportStatements?: boolean; + /** + * Allows completions to be formatted with snippet text, indicated by `CompletionItem["isSnippet"]`. + */ + readonly includeCompletionsWithSnippetText?: boolean; /** * If enabled, the completion list will include completions with invalid identifier names. * For those entries, The `insertText` and `replacementSpan` properties will be set to change from `.x` property access to `["x"]`. diff --git a/src/server/scriptInfo.ts b/src/server/scriptInfo.ts index 8c8fd274b5f5a..a26fcc6748932 100644 --- a/src/server/scriptInfo.ts +++ b/src/server/scriptInfo.ts @@ -415,6 +415,15 @@ namespace ts.server { return this.realpath && this.realpath !== this.path ? this.realpath : undefined; } + /** + * @internal + * Does not compute realpath; uses precomputed result. Use `ensureRealPath` + * first if a definite result is needed. + */ + isSymlink(): boolean | undefined { + return this.realpath && this.realpath !== this.path; + } + getFormatCodeSettings(): FormatCodeSettings | undefined { return this.formatSettings; } getPreferences(): protocol.UserPreferences | undefined { return this.preferences; } @@ -422,10 +431,10 @@ namespace ts.server { const isNew = !this.isAttached(project); if (isNew) { this.containingProjects.push(project); - project.onFileAddedOrRemoved(); if (!project.getCompilerOptions().preserveSymlinks) { this.ensureRealPath(); } + project.onFileAddedOrRemoved(this.isSymlink()); } return isNew; } @@ -447,23 +456,23 @@ namespace ts.server { return; case 1: if (this.containingProjects[0] === project) { - project.onFileAddedOrRemoved(); + project.onFileAddedOrRemoved(this.isSymlink()); this.containingProjects.pop(); } break; case 2: if (this.containingProjects[0] === project) { - project.onFileAddedOrRemoved(); + project.onFileAddedOrRemoved(this.isSymlink()); this.containingProjects[0] = this.containingProjects.pop()!; } else if (this.containingProjects[1] === project) { - project.onFileAddedOrRemoved(); + project.onFileAddedOrRemoved(this.isSymlink()); this.containingProjects.pop(); } break; default: if (unorderedRemoveItem(this.containingProjects, project)) { - project.onFileAddedOrRemoved(); + project.onFileAddedOrRemoved(this.isSymlink()); } break; } @@ -477,7 +486,7 @@ namespace ts.server { const existingRoot = p.getRootFilesMap().get(this.path); // detach is unnecessary since we'll clean the list of containing projects anyways p.removeFile(this, /*fileExists*/ false, /*detachFromProjects*/ false); - p.onFileAddedOrRemoved(); + p.onFileAddedOrRemoved(this.isSymlink()); // If the info was for the external or configured project's root, // add missing file as the root if (existingRoot && !isInferredProject(p)) { diff --git a/src/server/session.ts b/src/server/session.ts index a19284c0d523d..e1f92187b3d49 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1838,10 +1838,10 @@ namespace ts.server { const prefix = args.prefix || ""; const entries = stableSort(mapDefined(completions.entries, entry => { if (completions.isMemberCompletion || startsWith(entry.name.toLowerCase(), prefix.toLowerCase())) { - const { name, kind, kindModifiers, sortText, insertText, replacementSpan, hasAction, source, isRecommended, isPackageJsonImport, data } = entry; + const { name, kind, kindModifiers, sortText, insertText, replacementSpan, hasAction, source, sourceDisplay, isSnippet, isRecommended, isPackageJsonImport, data } = entry; const convertedSpan = replacementSpan ? toProtocolTextSpan(replacementSpan, scriptInfo) : undefined; // Use `hasAction || undefined` to avoid serializing `false`. - return { name, kind, kindModifiers, sortText, insertText, replacementSpan: convertedSpan, hasAction: hasAction || undefined, source, isRecommended, isPackageJsonImport, data }; + return { name, kind, kindModifiers, sortText, insertText, replacementSpan: convertedSpan, isSnippet, hasAction: hasAction || undefined, source, sourceDisplay, isRecommended, isPackageJsonImport, data }; } }), (a, b) => compareStringsCaseSensitiveUI(a.name, b.name)); diff --git a/src/services/codefixes/importFixes.ts b/src/services/codefixes/importFixes.ts index c1e28557bfbcf..364c8c3c7f638 100644 --- a/src/services/codefixes/importFixes.ts +++ b/src/services/codefixes/importFixes.ts @@ -156,11 +156,13 @@ namespace ts.codefix { readonly kind: ImportFixKind.UseNamespace; readonly namespacePrefix: string; readonly position: number; + readonly moduleSpecifier: string; } interface FixUseImportType { readonly kind: ImportFixKind.ImportType; readonly moduleSpecifier: string; readonly position: number; + readonly exportInfo: SymbolExportInfo; } interface FixAddToExistingImport { readonly kind: ImportFixKind.AddToExisting; @@ -175,21 +177,7 @@ namespace ts.codefix { readonly importKind: ImportKind; readonly typeOnly: boolean; readonly useRequire: boolean; - } - - const enum ImportKind { - Named, - Default, - Namespace, - CommonJS, - } - - /** Information about how a symbol is exported from a module. (We don't need to store the exported symbol, just its module.) */ - interface SymbolExportInfo { - readonly moduleSymbol: Symbol; - readonly importKind: ImportKind; - /** If true, can't use an es6 import from a js file. */ - readonly exportedSymbolIsTypeOnly: boolean; + readonly exportInfo?: SymbolExportInfo; } /** Information needed to augment an existing import declaration. */ @@ -211,42 +199,40 @@ namespace ts.codefix { ): { readonly moduleSpecifier: string, readonly codeAction: CodeAction } { const compilerOptions = program.getCompilerOptions(); const exportInfos = pathIsBareSpecifier(stripQuotes(moduleSymbol.name)) - ? [getSymbolExportInfoForSymbol(exportedSymbol, moduleSymbol, sourceFile, program, host)] + ? [getSymbolExportInfoForSymbol(exportedSymbol, moduleSymbol, program, host)] : getAllReExportingModules(sourceFile, exportedSymbol, moduleSymbol, symbolName, host, program, /*useAutoImportProvider*/ true); const useRequire = shouldUseRequire(sourceFile, program); const preferTypeOnlyImport = compilerOptions.importsNotUsedAsValues === ImportsNotUsedAsValues.Error && !isSourceFileJS(sourceFile) && isValidTypeOnlyAliasUseSite(getTokenAtPosition(sourceFile, position)); - const moduleSpecifier = getBestFix(getNewImportInfos(program, sourceFile, position, preferTypeOnlyImport, useRequire, exportInfos, host, preferences), sourceFile, program, host).moduleSpecifier; const fix = getImportFixForSymbol(sourceFile, exportInfos, moduleSymbol, symbolName, program, position, preferTypeOnlyImport, useRequire, host, preferences); - return { moduleSpecifier, codeAction: codeFixActionToCodeAction(codeActionForFix({ host, formatContext, preferences }, sourceFile, symbolName, fix, getQuotePreference(sourceFile, preferences))) }; + return { moduleSpecifier: fix.moduleSpecifier, codeAction: codeFixActionToCodeAction(codeActionForFix({ host, formatContext, preferences }, sourceFile, symbolName, fix, getQuotePreference(sourceFile, preferences))) }; } function getImportFixForSymbol(sourceFile: SourceFile, exportInfos: readonly SymbolExportInfo[], moduleSymbol: Symbol, symbolName: string, program: Program, position: number | undefined, preferTypeOnlyImport: boolean, useRequire: boolean, host: LanguageServiceHost, preferences: UserPreferences) { Debug.assert(exportInfos.some(info => info.moduleSymbol === moduleSymbol), "Some exportInfo should match the specified moduleSymbol"); - // We sort the best codefixes first, so taking `first` is best. - return getBestFix(getFixForImport(exportInfos, symbolName, position, preferTypeOnlyImport, useRequire, program, sourceFile, host, preferences), sourceFile, program, host); + return getBestFix(getImportFixes(exportInfos, symbolName, position, preferTypeOnlyImport, useRequire, program, sourceFile, host, preferences), sourceFile, host); } function codeFixActionToCodeAction({ description, changes, commands }: CodeFixAction): CodeAction { return { description, changes, commands }; } - function getSymbolExportInfoForSymbol(symbol: Symbol, moduleSymbol: Symbol, importingFile: SourceFile, program: Program, host: LanguageServiceHost): SymbolExportInfo { + function getSymbolExportInfoForSymbol(symbol: Symbol, moduleSymbol: Symbol, program: Program, host: LanguageServiceHost): SymbolExportInfo { const compilerOptions = program.getCompilerOptions(); - const mainProgramInfo = getInfoWithChecker(program.getTypeChecker()); + const mainProgramInfo = getInfoWithChecker(program.getTypeChecker(), /*isFromPackageJson*/ false); if (mainProgramInfo) { return mainProgramInfo; } const autoImportProvider = host.getPackageJsonAutoImportProvider?.()?.getTypeChecker(); - return Debug.checkDefined(autoImportProvider && getInfoWithChecker(autoImportProvider), `Could not find symbol in specified module for code actions`); + return Debug.checkDefined(autoImportProvider && getInfoWithChecker(autoImportProvider, /*isFromPackageJson*/ true), `Could not find symbol in specified module for code actions`); - function getInfoWithChecker(checker: TypeChecker): SymbolExportInfo | undefined { - const defaultInfo = getDefaultLikeExportInfo(importingFile, moduleSymbol, checker, compilerOptions); + function getInfoWithChecker(checker: TypeChecker, isFromPackageJson: boolean): SymbolExportInfo | undefined { + const defaultInfo = getDefaultLikeExportInfo(moduleSymbol, checker, compilerOptions); if (defaultInfo && skipAlias(defaultInfo.symbol, checker) === symbol) { - return { moduleSymbol, importKind: defaultInfo.kind, exportedSymbolIsTypeOnly: isTypeOnlySymbol(symbol, checker) }; + return { symbol: defaultInfo.symbol, moduleSymbol, exportKind: defaultInfo.exportKind, exportedSymbolIsTypeOnly: isTypeOnlySymbol(symbol, checker), isFromPackageJson }; } const named = checker.tryGetMemberInModuleExportsAndProperties(symbol.name, moduleSymbol); if (named && skipAlias(named, checker) === symbol) { - return { moduleSymbol, importKind: ImportKind.Named, exportedSymbolIsTypeOnly: isTypeOnlySymbol(symbol, checker) }; + return { symbol: named, moduleSymbol, exportKind: ExportKind.Named, exportedSymbolIsTypeOnly: isTypeOnlySymbol(symbol, checker), isFromPackageJson }; } } } @@ -254,25 +240,92 @@ namespace ts.codefix { function getAllReExportingModules(importingFile: SourceFile, exportedSymbol: Symbol, exportingModuleSymbol: Symbol, symbolName: string, host: LanguageServiceHost, program: Program, useAutoImportProvider: boolean): readonly SymbolExportInfo[] { const result: SymbolExportInfo[] = []; const compilerOptions = program.getCompilerOptions(); - forEachExternalModuleToImportFrom(program, host, importingFile, /*filterByPackageJson*/ false, useAutoImportProvider, (moduleSymbol, moduleFile, program) => { + const getModuleSpecifierResolutionHost = memoizeOne((isFromPackageJson: boolean) => { + return createModuleSpecifierResolutionHost(isFromPackageJson ? host.getPackageJsonAutoImportProvider!()! : program, host); + }); + + forEachExternalModuleToImportFrom(program, host, useAutoImportProvider, (moduleSymbol, moduleFile, program, isFromPackageJson) => { const checker = program.getTypeChecker(); // Don't import from a re-export when looking "up" like to `./index` or `../index`. if (moduleFile && moduleSymbol !== exportingModuleSymbol && startsWith(importingFile.fileName, getDirectoryPath(moduleFile.fileName))) { return; } - const defaultInfo = getDefaultLikeExportInfo(importingFile, moduleSymbol, checker, compilerOptions); - if (defaultInfo && (defaultInfo.name === symbolName || moduleSymbolToValidIdentifier(moduleSymbol, compilerOptions.target) === symbolName) && skipAlias(defaultInfo.symbol, checker) === exportedSymbol) { - result.push({ moduleSymbol, importKind: defaultInfo.kind, exportedSymbolIsTypeOnly: isTypeOnlySymbol(defaultInfo.symbol, checker) }); + const defaultInfo = getDefaultLikeExportInfo(moduleSymbol, checker, compilerOptions); + if (defaultInfo && (defaultInfo.name === symbolName || moduleSymbolToValidIdentifier(moduleSymbol, compilerOptions.target) === symbolName) && skipAlias(defaultInfo.symbol, checker) === exportedSymbol && isImportable(program, moduleFile, isFromPackageJson)) { + result.push({ symbol: defaultInfo.symbol, moduleSymbol, exportKind: defaultInfo.exportKind, exportedSymbolIsTypeOnly: isTypeOnlySymbol(defaultInfo.symbol, checker), isFromPackageJson }); + } + + for (const exported of checker.getExportsAndPropertiesOfModule(moduleSymbol)) { + if (exported.name === symbolName && skipAlias(exported, checker) === exportedSymbol && isImportable(program, moduleFile, isFromPackageJson)) { + result.push({ symbol: exported, moduleSymbol, exportKind: ExportKind.Named, exportedSymbolIsTypeOnly: isTypeOnlySymbol(exported, checker), isFromPackageJson }); + } + } + }); + return result; + + function isImportable(program: Program, moduleFile: SourceFile | undefined, isFromPackageJson: boolean) { + return !moduleFile || isImportableFile(program, importingFile, moduleFile, /*packageJsonFilter*/ undefined, getModuleSpecifierResolutionHost(isFromPackageJson), host.getModuleSpecifierCache?.()); + } + } + + export function getModuleSpecifierForBestExportInfo(exportInfo: readonly SymbolExportInfo[], + importingFile: SourceFile, + program: Program, + host: LanguageServiceHost, + preferences: UserPreferences + ): { exportInfo?: SymbolExportInfo, moduleSpecifier: string } { + return getBestFix(getNewImportFixes(program, importingFile, /*position*/ undefined, /*preferTypeOnlyImport*/ false, /*useRequire*/ false, exportInfo, host, preferences), importingFile, host); + } + + export function getSymbolToExportInfoMap(importingFile: SourceFile, host: LanguageServiceHost, program: Program) { + const start = timestamp(); + // Pulling the AutoImportProvider project will trigger its updateGraph if pending, + // which will invalidate the export map cache if things change, so pull it before + // checking the cache. + host.getPackageJsonAutoImportProvider?.(); + const cache = host.getExportMapCache?.(); + if (cache) { + const cached = cache.get(importingFile.path, program.getTypeChecker(), host.getProjectVersion?.()); + if (cached) { + host.log?.("getSymbolToExportInfoMap: cache hit"); + return cached; + } + else { + host.log?.("getSymbolToExportInfoMap: cache miss or empty; calculating new results"); } + } + const result: MultiMap = createMultiMap(); + const compilerOptions = program.getCompilerOptions(); + const target = getEmitScriptTarget(compilerOptions); + forEachExternalModuleToImportFrom(program, host, /*useAutoImportProvider*/ true, (moduleSymbol, _moduleFile, program, isFromPackageJson) => { + const checker = program.getTypeChecker(); + const defaultInfo = getDefaultLikeExportInfo(moduleSymbol, checker, compilerOptions); + if (defaultInfo) { + const name = getNameForExportedSymbol(getLocalSymbolForExportDefault(defaultInfo.symbol) || defaultInfo.symbol, target); + result.add(key(name, defaultInfo.symbol, moduleSymbol, checker), { symbol: defaultInfo.symbol, moduleSymbol, exportKind: defaultInfo.exportKind, exportedSymbolIsTypeOnly: isTypeOnlySymbol(defaultInfo.symbol, checker), isFromPackageJson }); + } + const seenExports = new Map(); for (const exported of checker.getExportsAndPropertiesOfModule(moduleSymbol)) { - if (exported.name === symbolName && skipAlias(exported, checker) === exportedSymbol) { - result.push({ moduleSymbol, importKind: ImportKind.Named, exportedSymbolIsTypeOnly: isTypeOnlySymbol(exported, checker) }); + if (exported !== defaultInfo?.symbol && addToSeen(seenExports, exported)) { + result.add(key(getNameForExportedSymbol(exported, target), exported, moduleSymbol, checker), { symbol: exported, moduleSymbol, exportKind: ExportKind.Named, exportedSymbolIsTypeOnly: isTypeOnlySymbol(exported, checker), isFromPackageJson }); } } }); + + if (cache) { + host.log?.("getSymbolToExportInfoMap: caching results"); + cache.set(result, host.getProjectVersion?.()); + } + host.log?.(`getSymbolToExportInfoMap: done in ${timestamp() - start} ms`); return result; + + function key(name: string, alias: Symbol, moduleSymbol: Symbol, checker: TypeChecker) { + const moduleName = stripQuotes(moduleSymbol.name); + const moduleKey = isExternalModuleNameRelative(moduleName) ? "/" : moduleName; + return `${name}|${getSymbolId(skipAlias(alias, checker))}|${moduleKey}`; + } } function isTypeOnlySymbol(s: Symbol, checker: TypeChecker): boolean { @@ -283,7 +336,7 @@ namespace ts.codefix { return isValidTypeOnlyAliasUseSite(getTokenAtPosition(sourceFile, position)); } - function getFixForImport( + function getImportFixes( exportInfos: readonly SymbolExportInfo[], symbolName: string, /** undefined only for missing JSX namespace */ @@ -296,7 +349,7 @@ namespace ts.codefix { preferences: UserPreferences, ): readonly ImportFix[] { const checker = program.getTypeChecker(); - const existingImports = flatMap(exportInfos, info => getExistingImportDeclarations(info, checker, sourceFile)); + const existingImports = flatMap(exportInfos, info => getExistingImportDeclarations(info, checker, sourceFile, program.getCompilerOptions())); const useNamespace = position === undefined ? undefined : tryUseExistingNamespaceImport(existingImports, symbolName, position, checker); const addToExisting = tryAddToExistingImport(existingImports, position !== undefined && isTypeOnlyPosition(sourceFile, position)); // Don't bother providing an action to add a new import if we can add to an existing one. @@ -319,10 +372,11 @@ namespace ts.codefix { // and it is up to the user to decide which one fits best. return firstDefined(existingImports, ({ declaration }): FixUseNamespaceImport | undefined => { const namespacePrefix = getNamespaceLikeImportText(declaration); - if (namespacePrefix) { + const moduleSpecifier = tryGetModuleSpecifierFromDeclaration(declaration); + if (namespacePrefix && moduleSpecifier) { const moduleSymbol = getTargetModuleFromNamespaceLikeImport(declaration, checker); if (moduleSymbol && moduleSymbol.exports!.has(escapeLeadingUnderscores(symbolName))) { - return { kind: ImportFixKind.UseNamespace, namespacePrefix, position }; + return { kind: ImportFixKind.UseNamespace, namespacePrefix, position, moduleSpecifier }; } } }); @@ -364,17 +418,19 @@ namespace ts.codefix { : undefined; } const { importClause } = declaration; - if (!importClause) return undefined; + if (!importClause || !isStringLiteralLike(declaration.moduleSpecifier)) return undefined; const { name, namedBindings } = importClause; return importKind === ImportKind.Default && !name || importKind === ImportKind.Named && (!namedBindings || namedBindings.kind === SyntaxKind.NamedImports) - ? { kind: ImportFixKind.AddToExisting, importClauseOrBindingPattern: importClause, importKind, moduleSpecifier: declaration.moduleSpecifier.getText(), canUseTypeOnlyImport } + ? { kind: ImportFixKind.AddToExisting, importClauseOrBindingPattern: importClause, importKind, moduleSpecifier: declaration.moduleSpecifier.text, canUseTypeOnlyImport } : undefined; }); } - function getExistingImportDeclarations({ moduleSymbol, importKind, exportedSymbolIsTypeOnly }: SymbolExportInfo, checker: TypeChecker, sourceFile: SourceFile): readonly FixAddToExistingImportInfo[] { + function getExistingImportDeclarations({ moduleSymbol, exportKind, exportedSymbolIsTypeOnly }: SymbolExportInfo, checker: TypeChecker, importingFile: SourceFile, compilerOptions: CompilerOptions): readonly FixAddToExistingImportInfo[] { // Can't use an es6 import for a type in JS. - return exportedSymbolIsTypeOnly && isSourceFileJS(sourceFile) ? emptyArray : mapDefined(sourceFile.imports, (moduleSpecifier): FixAddToExistingImportInfo | undefined => { + if (exportedSymbolIsTypeOnly && isSourceFileJS(importingFile)) return emptyArray; + const importKind = getImportKind(importingFile, exportKind, compilerOptions); + return mapDefined(importingFile.imports, (moduleSpecifier): FixAddToExistingImportInfo | undefined => { const i = importFromModuleSpecifier(moduleSpecifier); if (isRequireVariableDeclaration(i.parent)) { return checker.resolveExternalModuleName(moduleSpecifier) === moduleSymbol ? { declaration: i.parent, importKind } : undefined; @@ -412,7 +468,7 @@ namespace ts.codefix { return true; } - function getNewImportInfos( + function getNewImportFixes( program: Program, sourceFile: SourceFile, position: number | undefined, @@ -424,13 +480,14 @@ namespace ts.codefix { ): readonly (FixAddNewImport | FixUseImportType)[] { const isJs = isSourceFileJS(sourceFile); const compilerOptions = program.getCompilerOptions(); - return flatMap(moduleSymbols, ({ moduleSymbol, importKind, exportedSymbolIsTypeOnly }) => - moduleSpecifiers.getModuleSpecifiers(moduleSymbol, program.getTypeChecker(), compilerOptions, sourceFile, createModuleSpecifierResolutionHost(program, host), preferences) + const moduleSpecifierResolutionHost = createModuleSpecifierResolutionHost(program, host); + return flatMap(moduleSymbols, exportInfo => + moduleSpecifiers.getModuleSpecifiers(exportInfo.moduleSymbol, program.getTypeChecker(), compilerOptions, sourceFile, moduleSpecifierResolutionHost, preferences) .map((moduleSpecifier): FixAddNewImport | FixUseImportType => // `position` should only be undefined at a missing jsx namespace, in which case we shouldn't be looking for pure types. - exportedSymbolIsTypeOnly && isJs - ? { kind: ImportFixKind.ImportType, moduleSpecifier, position: Debug.checkDefined(position, "position should be defined") } - : { kind: ImportFixKind.AddNew, moduleSpecifier, importKind, useRequire, typeOnly: preferTypeOnlyImport })); + exportInfo.exportedSymbolIsTypeOnly && isJs && position !== undefined + ? { kind: ImportFixKind.ImportType, moduleSpecifier, position, exportInfo } + : { kind: ImportFixKind.AddNew, moduleSpecifier, importKind: getImportKind(sourceFile, exportInfo.exportKind, compilerOptions), useRequire, typeOnly: preferTypeOnlyImport, exportInfo })); } function getFixesForAddImport( @@ -445,16 +502,13 @@ namespace ts.codefix { preferences: UserPreferences, ): readonly (FixAddNewImport | FixUseImportType)[] { const existingDeclaration = firstDefined(existingImports, info => newImportInfoFromExistingSpecifier(info, preferTypeOnlyImport, useRequire)); - return existingDeclaration ? [existingDeclaration] : getNewImportInfos(program, sourceFile, position, preferTypeOnlyImport, useRequire, exportInfos, host, preferences); + return existingDeclaration ? [existingDeclaration] : getNewImportFixes(program, sourceFile, position, preferTypeOnlyImport, useRequire, exportInfos, host, preferences); } function newImportInfoFromExistingSpecifier({ declaration, importKind }: FixAddToExistingImportInfo, preferTypeOnlyImport: boolean, useRequire: boolean): FixAddNewImport | undefined { - const moduleSpecifier = declaration.kind === SyntaxKind.ImportDeclaration ? declaration.moduleSpecifier : - declaration.kind === SyntaxKind.VariableDeclaration ? declaration.initializer.arguments[0] : - declaration.moduleReference.kind === SyntaxKind.ExternalModuleReference ? declaration.moduleReference.expression : - undefined; - return moduleSpecifier && isStringLiteral(moduleSpecifier) - ? { kind: ImportFixKind.AddNew, moduleSpecifier: moduleSpecifier.text, importKind, typeOnly: preferTypeOnlyImport, useRequire } + const moduleSpecifier = tryGetModuleSpecifierFromDeclaration(declaration); + return moduleSpecifier + ? { kind: ImportFixKind.AddNew, moduleSpecifier, importKind, typeOnly: preferTypeOnlyImport, useRequire } : undefined; } @@ -464,20 +518,20 @@ namespace ts.codefix { const info = errorCode === Diagnostics._0_refers_to_a_UMD_global_but_the_current_file_is_a_module_Consider_adding_an_import_instead.code ? getFixesInfoForUMDImport(context, symbolToken) : isIdentifier(symbolToken) ? getFixesInfoForNonUMDImport(context, symbolToken, useAutoImportProvider) : undefined; - return info && { ...info, fixes: sortFixes(info.fixes, context.sourceFile, context.program, context.host) }; + return info && { ...info, fixes: sortFixes(info.fixes, context.sourceFile, context.host) }; } - function sortFixes(fixes: readonly ImportFix[], sourceFile: SourceFile, program: Program, host: LanguageServiceHost): readonly ImportFix[] { - const { allowsImportingSpecifier } = createAutoImportFilter(sourceFile, program, host); + function sortFixes(fixes: readonly ImportFix[], sourceFile: SourceFile, host: LanguageServiceHost): readonly ImportFix[] { + const { allowsImportingSpecifier } = createPackageJsonImportFilter(sourceFile, host); return sort(fixes, (a, b) => compareValues(a.kind, b.kind) || compareModuleSpecifiers(a, b, allowsImportingSpecifier)); } - function getBestFix(fixes: readonly T[], sourceFile: SourceFile, program: Program, host: LanguageServiceHost): T { + function getBestFix(fixes: readonly T[], sourceFile: SourceFile, host: LanguageServiceHost): T { // These will always be placed first if available, and are better than other kinds if (fixes[0].kind === ImportFixKind.UseNamespace || fixes[0].kind === ImportFixKind.AddToExisting) { return fixes[0]; } - const { allowsImportingSpecifier } = createAutoImportFilter(sourceFile, program, host); + const { allowsImportingSpecifier } = createPackageJsonImportFilter(sourceFile, host); return fixes.reduce((best, fix) => compareModuleSpecifiers(fix, best, allowsImportingSpecifier) === Comparison.LessThan ? fix : best ); @@ -497,9 +551,9 @@ namespace ts.codefix { if (!umdSymbol) return undefined; const symbol = checker.getAliasedSymbol(umdSymbol); const symbolName = umdSymbol.name; - const exportInfos: readonly SymbolExportInfo[] = [{ moduleSymbol: symbol, importKind: getUmdImportKind(sourceFile, program.getCompilerOptions()), exportedSymbolIsTypeOnly: false }]; + const exportInfos: readonly SymbolExportInfo[] = [{ symbol: umdSymbol, moduleSymbol: symbol, exportKind: ExportKind.UMD, exportedSymbolIsTypeOnly: false, isFromPackageJson: false }]; const useRequire = shouldUseRequire(sourceFile, program); - const fixes = getFixForImport(exportInfos, symbolName, isIdentifier(token) ? token.getStart(sourceFile) : undefined, /*preferTypeOnlyImport*/ false, useRequire, program, sourceFile, host, preferences); + const fixes = getImportFixes(exportInfos, symbolName, isIdentifier(token) ? token.getStart(sourceFile) : undefined, /*preferTypeOnlyImport*/ false, useRequire, program, sourceFile, host, preferences); return { fixes, symbolName }; } function getUmdSymbol(token: Node, checker: TypeChecker): Symbol | undefined { @@ -514,6 +568,16 @@ namespace ts.codefix { : undefined; } + export function getImportKind(importingFile: SourceFile, exportKind: ExportKind, compilerOptions: CompilerOptions): ImportKind { + switch (exportKind) { + case ExportKind.Named: return ImportKind.Named; + case ExportKind.Default: return ImportKind.Default; + case ExportKind.ExportEquals: return getExportEqualsImportKind(importingFile, compilerOptions); + case ExportKind.UMD: return getUmdImportKind(importingFile, compilerOptions); + default: return Debug.assertNever(exportKind); + } + } + function getUmdImportKind(importingFile: SourceFile, compilerOptions: CompilerOptions): ImportKind { // Import a synthetic `default` if enabled. if (getAllowSyntheticDefaultImports(compilerOptions)) { @@ -553,7 +617,7 @@ namespace ts.codefix { const useRequire = shouldUseRequire(sourceFile, program); const exportInfos = getExportInfos(symbolName, getMeaningFromLocation(symbolToken), cancellationToken, sourceFile, program, useAutoImportProvider, host); const fixes = arrayFrom(flatMapIterator(exportInfos.entries(), ([_, exportInfos]) => - getFixForImport(exportInfos, symbolName, symbolToken.getStart(sourceFile), preferTypeOnlyImport, useRequire, program, sourceFile, host, preferences))); + getImportFixes(exportInfos, symbolName, symbolToken.getStart(sourceFile), preferTypeOnlyImport, useRequire, program, sourceFile, host, preferences))); return { fixes, symbolName }; } @@ -573,7 +637,7 @@ namespace ts.codefix { symbolName: string, currentTokenMeaning: SemanticMeaning, cancellationToken: CancellationToken, - sourceFile: SourceFile, + fromFile: SourceFile, program: Program, useAutoImportProvider: boolean, host: LanguageServiceHost @@ -581,43 +645,52 @@ namespace ts.codefix { // For each original symbol, keep all re-exports of that symbol together so we can call `getCodeActionsForImport` on the whole group at once. // Maps symbol id to info for modules providing that symbol (original export + re-exports). const originalSymbolToExportInfos = createMultiMap(); - function addSymbol(moduleSymbol: Symbol, exportedSymbol: Symbol, importKind: ImportKind, checker: TypeChecker): void { - originalSymbolToExportInfos.add(getUniqueSymbolId(exportedSymbol, checker).toString(), { moduleSymbol, importKind, exportedSymbolIsTypeOnly: isTypeOnlySymbol(exportedSymbol, checker) }); + const packageJsonFilter = createPackageJsonImportFilter(fromFile, host); + const moduleSpecifierCache = host.getModuleSpecifierCache?.(); + const getModuleSpecifierResolutionHost = memoizeOne((isFromPackageJson: boolean) => { + return createModuleSpecifierResolutionHost(isFromPackageJson ? host.getPackageJsonAutoImportProvider!()! : program, host); + }); + function addSymbol(moduleSymbol: Symbol, toFile: SourceFile | undefined, exportedSymbol: Symbol, exportKind: ExportKind, program: Program, isFromPackageJson: boolean): void { + const moduleSpecifierResolutionHost = getModuleSpecifierResolutionHost(isFromPackageJson); + if (toFile && isImportableFile(program, fromFile, toFile, packageJsonFilter, moduleSpecifierResolutionHost, moduleSpecifierCache) || + !toFile && packageJsonFilter.allowsImportingAmbientModule(moduleSymbol, moduleSpecifierResolutionHost) + ) { + const checker = program.getTypeChecker(); + originalSymbolToExportInfos.add(getUniqueSymbolId(exportedSymbol, checker).toString(), { symbol: exportedSymbol, moduleSymbol, exportKind, exportedSymbolIsTypeOnly: isTypeOnlySymbol(exportedSymbol, checker), isFromPackageJson }); + } } - forEachExternalModuleToImportFrom(program, host, sourceFile, /*filterByPackageJson*/ true, useAutoImportProvider, (moduleSymbol, _, program) => { + forEachExternalModuleToImportFrom(program, host, useAutoImportProvider, (moduleSymbol, sourceFile, program, isFromPackageJson) => { const checker = program.getTypeChecker(); cancellationToken.throwIfCancellationRequested(); const compilerOptions = program.getCompilerOptions(); - const defaultInfo = getDefaultLikeExportInfo(sourceFile, moduleSymbol, checker, compilerOptions); + const defaultInfo = getDefaultLikeExportInfo(moduleSymbol, checker, compilerOptions); if (defaultInfo && (defaultInfo.name === symbolName || moduleSymbolToValidIdentifier(moduleSymbol, compilerOptions.target) === symbolName) && symbolHasMeaning(defaultInfo.symbolForMeaning, currentTokenMeaning)) { - addSymbol(moduleSymbol, defaultInfo.symbol, defaultInfo.kind, checker); + addSymbol(moduleSymbol, sourceFile, defaultInfo.symbol, defaultInfo.exportKind, program, isFromPackageJson); } // check exports with the same name const exportSymbolWithIdenticalName = checker.tryGetMemberInModuleExportsAndProperties(symbolName, moduleSymbol); if (exportSymbolWithIdenticalName && symbolHasMeaning(exportSymbolWithIdenticalName, currentTokenMeaning)) { - addSymbol(moduleSymbol, exportSymbolWithIdenticalName, ImportKind.Named, checker); + addSymbol(moduleSymbol, sourceFile, exportSymbolWithIdenticalName, ExportKind.Named, program, isFromPackageJson); } }); return originalSymbolToExportInfos; } - function getDefaultLikeExportInfo( - importingFile: SourceFile, moduleSymbol: Symbol, checker: TypeChecker, compilerOptions: CompilerOptions, - ): { readonly symbol: Symbol, readonly symbolForMeaning: Symbol, readonly name: string, readonly kind: ImportKind } | undefined { - const exported = getDefaultLikeExportWorker(importingFile, moduleSymbol, checker, compilerOptions); + function getDefaultLikeExportInfo(moduleSymbol: Symbol, checker: TypeChecker, compilerOptions: CompilerOptions) { + const exported = getDefaultLikeExportWorker(moduleSymbol, checker); if (!exported) return undefined; - const { symbol, kind } = exported; + const { symbol, exportKind } = exported; const info = getDefaultExportInfoWorker(symbol, checker, compilerOptions); - return info && { symbol, kind, ...info }; + return info && { symbol, exportKind, ...info }; } - function getDefaultLikeExportWorker(importingFile: SourceFile, moduleSymbol: Symbol, checker: TypeChecker, compilerOptions: CompilerOptions): { readonly symbol: Symbol, readonly kind: ImportKind } | undefined { - const defaultExport = checker.tryGetMemberInModuleExports(InternalSymbolName.Default, moduleSymbol); - if (defaultExport) return { symbol: defaultExport, kind: ImportKind.Default }; + function getDefaultLikeExportWorker(moduleSymbol: Symbol, checker: TypeChecker): { readonly symbol: Symbol, readonly exportKind: ExportKind } | undefined { const exportEquals = checker.resolveExternalModuleSymbol(moduleSymbol); - return exportEquals === moduleSymbol ? undefined : { symbol: exportEquals, kind: getExportEqualsImportKind(importingFile, compilerOptions) }; + if (exportEquals !== moduleSymbol) return { symbol: exportEquals, exportKind: ExportKind.ExportEquals }; + const defaultExport = checker.tryGetMemberInModuleExports(InternalSymbolName.Default, moduleSymbol); + if (defaultExport) return { symbol: defaultExport, exportKind: ExportKind.Default }; } function getExportEqualsImportKind(importingFile: SourceFile, compilerOptions: CompilerOptions): ImportKind { @@ -636,7 +709,8 @@ namespace ts.codefix { // really hate that, so look to see if the importing file has any precedent // on how to handle it. for (const statement of importingFile.statements) { - if (isImportEqualsDeclaration(statement)) { + // `import foo` parses as an ImportEqualsDeclaration even though it could be an ImportDeclaration + if (isImportEqualsDeclaration(statement) && !nodeIsMissing(statement.moduleReference)) { return ImportKind.CommonJS; } } @@ -880,57 +954,23 @@ namespace ts.codefix { export function forEachExternalModuleToImportFrom( program: Program, host: LanguageServiceHost, - from: SourceFile, - filterByPackageJson: boolean, useAutoImportProvider: boolean, cb: (module: Symbol, moduleFile: SourceFile | undefined, program: Program, isFromPackageJson: boolean) => void, ) { - forEachExternalModuleToImportFromInProgram(program, host, from, filterByPackageJson, (module, file) => cb(module, file, program, /*isFromPackageJson*/ false)); + forEachExternalModule(program.getTypeChecker(), program.getSourceFiles(), (module, file) => cb(module, file, program, /*isFromPackageJson*/ false)); const autoImportProvider = useAutoImportProvider && host.getPackageJsonAutoImportProvider?.(); if (autoImportProvider) { const start = timestamp(); - forEachExternalModuleToImportFromInProgram(autoImportProvider, host, from, filterByPackageJson, (module, file) => cb(module, file, autoImportProvider, /*isFromPackageJson*/ true)); + forEachExternalModule(autoImportProvider.getTypeChecker(), autoImportProvider.getSourceFiles(), (module, file) => cb(module, file, autoImportProvider, /*isFromPackageJson*/ true)); host.log?.(`forEachExternalModuleToImportFrom autoImportProvider: ${timestamp() - start}`); } } - function forEachExternalModuleToImportFromInProgram( - program: Program, - host: LanguageServiceHost, - from: SourceFile, - filterByPackageJson: boolean, - cb: (module: Symbol, moduleFile: SourceFile | undefined) => void, - ) { - let filteredCount = 0; - const moduleSpecifierResolutionHost = createModuleSpecifierResolutionHost(program, host); - const packageJson = filterByPackageJson && createAutoImportFilter(from, program, host, moduleSpecifierResolutionHost); - forEachExternalModule(program.getTypeChecker(), program.getSourceFiles(), (module, sourceFile) => { - if (sourceFile === undefined) { - if (!packageJson || packageJson.allowsImportingAmbientModule(module)) { - cb(module, sourceFile); - } - else if (packageJson) { - filteredCount++; - } - } - else if (sourceFile && - sourceFile !== from && - isImportableFile(program, from, sourceFile, moduleSpecifierResolutionHost) - ) { - if (!packageJson || packageJson.allowsImportingSourceFile(sourceFile)) { - cb(module, sourceFile); - } - else if (packageJson) { - filteredCount++; - } - } - }); - host.log?.(`forEachExternalModuleToImportFrom: filtered out ${filteredCount} modules by package.json contents`); - } - function forEachExternalModule(checker: TypeChecker, allSourceFiles: readonly SourceFile[], cb: (module: Symbol, sourceFile: SourceFile | undefined) => void) { for (const ambient of checker.getAmbientModules()) { - cb(ambient, /*sourceFile*/ undefined); + if (!stringContains(ambient.name, "*")) { + cb(ambient, /*sourceFile*/ undefined); + } } for (const sourceFile of allSourceFiles) { if (isExternalOrCommonJsModule(sourceFile)) { @@ -939,42 +979,6 @@ namespace ts.codefix { } } - function isImportableFile( - program: Program, - from: SourceFile, - to: SourceFile, - moduleSpecifierResolutionHost: ModuleSpecifierResolutionHost - ) { - const getCanonicalFileName = hostGetCanonicalFileName(moduleSpecifierResolutionHost); - const globalTypingsCache = moduleSpecifierResolutionHost.getGlobalTypingsCacheLocation?.(); - return !!moduleSpecifiers.forEachFileNameOfModule( - from.fileName, - to.fileName, - moduleSpecifierResolutionHost, - /*preferSymlinks*/ false, - toPath => { - const toFile = program.getSourceFile(toPath); - // Determine to import using toPath only if toPath is what we were looking at - // or there doesnt exist the file in the program by the symlink - return (toFile === to || !toFile) && - isImportablePath(from.fileName, toPath, getCanonicalFileName, globalTypingsCache); - } - ); - } - - /** - * Don't include something from a `node_modules` that isn't actually reachable by a global import. - * A relative import to node_modules is usually a bad idea. - */ - function isImportablePath(fromPath: string, toPath: string, getCanonicalFileName: GetCanonicalFileName, globalCachePath?: string): boolean { - // If it's in a `node_modules` but is not reachable from here via a global import, don't bother. - const toNodeModules = forEachAncestorDirectory(toPath, ancestor => getBaseFileName(ancestor) === "node_modules" ? ancestor : undefined); - const toNodeModulesParent = toNodeModules && getDirectoryPath(getCanonicalFileName(toNodeModules)); - return toNodeModulesParent === undefined - || startsWith(getCanonicalFileName(fromPath), toNodeModulesParent) - || (!!globalCachePath && startsWith(getCanonicalFileName(globalCachePath), toNodeModulesParent)); - } - export function moduleSymbolToValidIdentifier(moduleSymbol: Symbol, target: ScriptTarget | undefined): string { return moduleSpecifierToValidIdentifier(removeFileExtension(stripQuotes(moduleSymbol.name)), target); } @@ -1005,117 +1009,4 @@ namespace ts.codefix { // Need `|| "_"` to ensure result isn't empty. return !isStringANonContextualKeyword(res) ? res || "_" : `_${res}`; } - - function createAutoImportFilter(fromFile: SourceFile, program: Program, host: LanguageServiceHost, moduleSpecifierResolutionHost = createModuleSpecifierResolutionHost(program, host)) { - const packageJsons = ( - (host.getPackageJsonsVisibleToFile && host.getPackageJsonsVisibleToFile(fromFile.fileName)) || getPackageJsonsVisibleToFile(fromFile.fileName, host) - ).filter(p => p.parseable); - - let usesNodeCoreModules: boolean | undefined; - return { allowsImportingAmbientModule, allowsImportingSourceFile, allowsImportingSpecifier, moduleSpecifierResolutionHost }; - - function moduleSpecifierIsCoveredByPackageJson(specifier: string) { - const packageName = getNodeModuleRootSpecifier(specifier); - for (const packageJson of packageJsons) { - if (packageJson.has(packageName) || packageJson.has(getTypesPackageName(packageName))) { - return true; - } - } - return false; - } - - function allowsImportingAmbientModule(moduleSymbol: Symbol): boolean { - if (!packageJsons.length || !moduleSymbol.valueDeclaration) { - return true; - } - - const declaringSourceFile = moduleSymbol.valueDeclaration.getSourceFile(); - const declaringNodeModuleName = getNodeModulesPackageNameFromFileName(declaringSourceFile.fileName); - if (typeof declaringNodeModuleName === "undefined") { - return true; - } - - const declaredModuleSpecifier = stripQuotes(moduleSymbol.getName()); - if (isAllowedCoreNodeModulesImport(declaredModuleSpecifier)) { - return true; - } - - return moduleSpecifierIsCoveredByPackageJson(declaringNodeModuleName) - || moduleSpecifierIsCoveredByPackageJson(declaredModuleSpecifier); - } - - function allowsImportingSourceFile(sourceFile: SourceFile): boolean { - if (!packageJsons.length) { - return true; - } - - const moduleSpecifier = getNodeModulesPackageNameFromFileName(sourceFile.fileName); - if (!moduleSpecifier) { - return true; - } - - return moduleSpecifierIsCoveredByPackageJson(moduleSpecifier); - } - - /** - * Use for a specific module specifier that has already been resolved. - * Use `allowsImportingAmbientModule` or `allowsImportingSourceFile` to resolve - * the best module specifier for a given module _and_ determine if it’s importable. - */ - function allowsImportingSpecifier(moduleSpecifier: string) { - if (!packageJsons.length || isAllowedCoreNodeModulesImport(moduleSpecifier)) { - return true; - } - if (pathIsRelative(moduleSpecifier) || isRootedDiskPath(moduleSpecifier)) { - return true; - } - return moduleSpecifierIsCoveredByPackageJson(moduleSpecifier); - } - - function isAllowedCoreNodeModulesImport(moduleSpecifier: string) { - // If we’re in JavaScript, it can be difficult to tell whether the user wants to import - // from Node core modules or not. We can start by seeing if the user is actually using - // any node core modules, as opposed to simply having @types/node accidentally as a - // dependency of a dependency. - if (isSourceFileJS(fromFile) && JsTyping.nodeCoreModules.has(moduleSpecifier)) { - if (usesNodeCoreModules === undefined) { - usesNodeCoreModules = consumesNodeCoreModules(fromFile); - } - if (usesNodeCoreModules) { - return true; - } - } - return false; - } - - function getNodeModulesPackageNameFromFileName(importedFileName: string): string | undefined { - if (!stringContains(importedFileName, "node_modules")) { - return undefined; - } - const specifier = moduleSpecifiers.getNodeModulesPackageName( - host.getCompilationSettings(), - fromFile.path, - importedFileName, - moduleSpecifierResolutionHost, - ); - - if (!specifier) { - return undefined; - } - // Paths here are not node_modules, so we don’t care about them; - // returning anything will trigger a lookup in package.json. - if (!pathIsRelative(specifier) && !isRootedDiskPath(specifier)) { - return getNodeModuleRootSpecifier(specifier); - } - } - - function getNodeModuleRootSpecifier(fullSpecifier: string): string { - const components = getPathComponents(getPackageNameFromTypesPackageName(fullSpecifier)).slice(1); - // Scoped packages - if (startsWith(components[0], "@")) { - return `${components[0]}/${components[1]}`; - } - return components[0]; - } - } } diff --git a/src/services/completions.ts b/src/services/completions.ts index 8cd4600674112..3399233374452 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -34,6 +34,7 @@ namespace ts.Completions { Export = 1 << 2, Promise = 1 << 3, Nullable = 1 << 4, + ResolvedExport = 1 << 5, SymbolMemberNoExport = SymbolMember, SymbolMemberExport = SymbolMember | Export, @@ -41,15 +42,24 @@ namespace ts.Completions { interface SymbolOriginInfo { kind: SymbolOriginInfoKind; + symbolName?: string; + moduleSymbol?: Symbol; + isDefaultExport?: boolean; + isFromPackageJson?: boolean; + exportName?: string; + fileName?: string; + moduleSpecifier?: string; } interface SymbolOriginInfoExport extends SymbolOriginInfo { - kind: SymbolOriginInfoKind; + symbolName: string; moduleSymbol: Symbol; isDefaultExport: boolean; - isFromPackageJson?: boolean; exportName: string; - fileName?: string; + } + + interface SymbolOriginInfoResolvedExport extends SymbolOriginInfoExport { + moduleSpecifier: string; } function originIsThisType(origin: SymbolOriginInfo): boolean { @@ -64,8 +74,16 @@ namespace ts.Completions { return !!(origin && origin.kind & SymbolOriginInfoKind.Export); } + function originIsResolvedExport(origin: SymbolOriginInfo | undefined): origin is SymbolOriginInfoResolvedExport { + return !!(origin && origin.kind === SymbolOriginInfoKind.ResolvedExport); + } + + function originIncludesSymbolName(origin: SymbolOriginInfo | undefined): origin is SymbolOriginInfoExport | SymbolOriginInfoResolvedExport { + return originIsExport(origin) || originIsResolvedExport(origin); + } + function originIsPackageJsonImport(origin: SymbolOriginInfo | undefined): origin is SymbolOriginInfoExport { - return originIsExport(origin) && !!origin.isFromPackageJson; + return (originIsExport(origin) || originIsResolvedExport(origin)) && !!origin.isFromPackageJson; } function originIsPromise(origin: SymbolOriginInfo): boolean { @@ -82,11 +100,12 @@ namespace ts.Completions { } /** - * Map from symbol id -> SymbolOriginInfo. + * Map from symbol index in `symbols` -> SymbolOriginInfo. * Only populated for symbols that come from other modules. */ - type SymbolOriginInfoMap = (SymbolOriginInfo | SymbolOriginInfoExport | undefined)[]; + type SymbolOriginInfoMap = Record; + /** Map from symbol id -> SortText. */ type SymbolSortTextMap = (SortText | undefined)[]; const enum KeywordCompletionFilters { @@ -103,62 +122,6 @@ namespace ts.Completions { const enum GlobalsSearch { Continue, Success, Fail } - export interface AutoImportSuggestion { - symbol: Symbol; - symbolName: string; - origin: SymbolOriginInfoExport; - } - export interface ImportSuggestionsForFileCache { - clear(): void; - get(fileName: string, checker: TypeChecker, projectVersion?: string): readonly AutoImportSuggestion[] | undefined; - set(fileName: string, suggestions: readonly AutoImportSuggestion[], projectVersion?: string): void; - isEmpty(): boolean; - } - export function createImportSuggestionsForFileCache(): ImportSuggestionsForFileCache { - let cache: readonly AutoImportSuggestion[] | undefined; - let projectVersion: string | undefined; - let fileName: string | undefined; - return { - isEmpty() { - return !cache; - }, - clear: () => { - cache = undefined; - fileName = undefined; - projectVersion = undefined; - }, - set: (file, suggestions, version) => { - cache = suggestions; - fileName = file; - if (version) { - projectVersion = version; - } - }, - get: (file, checker, version) => { - if (file !== fileName) { - return undefined; - } - if (version) { - return projectVersion === version ? cache : undefined; - } - forEach(cache, suggestion => { - // If the symbol/moduleSymbol was a merged symbol, it will have a new identity - // in the checker, even though the symbols to merge are the same (guaranteed by - // cache invalidation in synchronizeHostData). - if (suggestion.symbol.declarations?.length) { - suggestion.symbol = checker.getMergedSymbol(suggestion.origin.isDefaultExport - ? suggestion.symbol.declarations[0].localSymbol ?? suggestion.symbol.declarations[0].symbol - : suggestion.symbol.declarations[0].symbol); - } - if (suggestion.origin.moduleSymbol.declarations?.length) { - suggestion.origin.moduleSymbol = checker.getMergedSymbol(suggestion.origin.moduleSymbol.declarations[0].symbol); - } - }); - return cache; - }, - }; - } - export function getCompletionsAtPosition( host: LanguageServiceHost, program: Program, @@ -176,6 +139,15 @@ namespace ts.Completions { return undefined; } + if (triggerCharacter === " ") { + // `isValidTrigger` ensures we are at `import |` + if (preferences.includeCompletionsForImportStatements && preferences.includeCompletionsWithInsertText) { + return { isGlobalCompletion: true, isMemberCompletion: false, isNewIdentifierLocation: true, isIncomplete: true, entries: [] }; + } + return undefined; + + } + const stringCompletions = StringCompletions.getStringLiteralCompletions(sourceFile, position, contextToken, typeChecker, compilerOptions, host, log, preferences); if (stringCompletions) { return stringCompletions; @@ -229,6 +201,9 @@ namespace ts.Completions { symbolToOriginInfoMap, recommendedCompletion, isJsxInitializer, + isTypeOnlyLocation, + isJsxIdentifierExpected, + importCompletionNode, insideJsDocTagTypeExpression, symbolToSortTextMap, } = completionData; @@ -255,14 +230,17 @@ namespace ts.Completions { log, completionKind, preferences, + compilerOptions, + isTypeOnlyLocation, propertyAccessToConvert, - completionData.isJsxIdentifierExpected, + isJsxIdentifierExpected, isJsxInitializer, + importCompletionNode, recommendedCompletion, symbolToOriginInfoMap, symbolToSortTextMap ); - getJSCompletionEntries(sourceFile, location!.pos, uniqueNames, compilerOptions.target!, entries); // TODO: GH#18217 + getJSCompletionEntries(sourceFile, location.pos, uniqueNames, compilerOptions.target!, entries); // TODO: GH#18217 } else { if (!isNewIdentifierLocation && (!symbols || symbols.length === 0) && keywordFilters === KeywordCompletionFilters.None) { @@ -280,9 +258,12 @@ namespace ts.Completions { log, completionKind, preferences, + compilerOptions, + isTypeOnlyLocation, propertyAccessToConvert, - completionData.isJsxIdentifierExpected, + isJsxIdentifierExpected, isJsxInitializer, + importCompletionNode, recommendedCompletion, symbolToOriginInfoMap, symbolToSortTextMap @@ -410,7 +391,7 @@ namespace ts.Completions { symbol: Symbol, sortText: SortText, contextToken: Node | undefined, - location: Node | undefined, + location: Node, sourceFile: SourceFile, typeChecker: TypeChecker, name: string, @@ -419,11 +400,16 @@ namespace ts.Completions { recommendedCompletion: Symbol | undefined, propertyAccessToConvert: PropertyAccessExpression | undefined, isJsxInitializer: IsJsxInitializer | undefined, + importCompletionNode: Node | undefined, + useSemicolons: boolean, + options: CompilerOptions, preferences: UserPreferences, ): CompletionEntry | undefined { let insertText: string | undefined; let replacementSpan = getReplacementSpanForContextToken(contextToken); let data: CompletionEntryData | undefined; + let isSnippet: true | undefined; + let sourceDisplay; const insertQuestionDot = origin && originIsNullableMember(origin); const useBraces = origin && originIsSymbolMember(origin) || needsConvertPropertyAccess; @@ -470,16 +456,24 @@ namespace ts.Completions { replacementSpan = createTextSpanFromBounds(propertyAccessToConvert.getStart(sourceFile), propertyAccessToConvert.end); } + if (originIsResolvedExport(origin)) { + Debug.assertIsDefined(importCompletionNode); + ({ insertText, replacementSpan } = getInsertTextAndReplacementSpanForImportCompletion(name, importCompletionNode, origin, useSemicolons, options, preferences)); + sourceDisplay = [textPart(origin.moduleSpecifier)]; + isSnippet = preferences.includeCompletionsWithSnippetText ? true : undefined; + } + if (insertText !== undefined && !preferences.includeCompletionsWithInsertText) { return undefined; } - if (originIsExport(origin)) { + if (originIsExport(origin) || originIsResolvedExport(origin)) { data = { exportName: origin.exportName, fileName: origin.fileName, ambientModuleName: origin.fileName ? undefined : stripQuotes(origin.moduleSymbol.name), isPackageJsonImport: origin.isFromPackageJson ? true : undefined, + moduleSpecifier: originIsResolvedExport(origin) ? origin.moduleSpecifier : undefined, }; } @@ -493,7 +487,7 @@ namespace ts.Completions { // entries (like JavaScript identifier entries). return { name, - kind: SymbolDisplay.getSymbolKind(typeChecker, symbol, location!), // TODO: GH#18217 + kind: SymbolDisplay.getSymbolKind(typeChecker, symbol, location), // TODO: GH#18217 kindModifiers: SymbolDisplay.getSymbolModifiers(typeChecker, symbol), sortText, source: getSourceFromOrigin(origin), @@ -501,11 +495,32 @@ namespace ts.Completions { isRecommended: isRecommendedCompletionMatch(symbol, recommendedCompletion, typeChecker) || undefined, insertText, replacementSpan, + sourceDisplay, + isSnippet, isPackageJsonImport: originIsPackageJsonImport(origin) || undefined, data, }; } + function getInsertTextAndReplacementSpanForImportCompletion(name: string, importCompletionNode: Node, origin: SymbolOriginInfoResolvedExport, useSemicolons: boolean, options: CompilerOptions, preferences: UserPreferences) { + const sourceFile = importCompletionNode.getSourceFile(); + const replacementSpan = createTextSpanFromNode(importCompletionNode, sourceFile); + const quotedModuleSpecifier = quote(sourceFile, preferences, origin.moduleSpecifier); + const exportKind = + origin.isDefaultExport ? ExportKind.Default : + origin.exportName === InternalSymbolName.ExportEquals ? ExportKind.ExportEquals : + ExportKind.Named; + const tabStop = preferences.includeCompletionsWithSnippetText ? "$1" : ""; + const importKind = codefix.getImportKind(sourceFile, exportKind, options); + const suffix = useSemicolons ? ";" : ""; + switch (importKind) { + case ImportKind.CommonJS: return { replacementSpan, insertText: `import ${name}${tabStop} = require(${quotedModuleSpecifier})${suffix}` }; + case ImportKind.Default: return { replacementSpan, insertText: `import ${name}${tabStop} from ${quotedModuleSpecifier}${suffix}` }; + case ImportKind.Namespace: return { replacementSpan, insertText: `import * as ${name}${tabStop} from ${quotedModuleSpecifier}${suffix}` }; + case ImportKind.Named: return { replacementSpan, insertText: `import { ${name}${tabStop} } from ${quotedModuleSpecifier}${suffix}` }; + } + } + function quotePropertyName(sourceFile: SourceFile, preferences: UserPreferences, name: string,): string { if (/^\d+$/.test(name)) { return name; @@ -523,6 +538,9 @@ namespace ts.Completions { if (originIsExport(origin)) { return stripQuotes(origin.moduleSymbol.name); } + if (originIsResolvedExport(origin)) { + return origin.moduleSpecifier; + } if (origin?.kind === SymbolOriginInfoKind.ThisType) { return CompletionSource.ThisProperty; } @@ -532,37 +550,40 @@ namespace ts.Completions { symbols: readonly Symbol[], entries: Push, contextToken: Node | undefined, - location: Node | undefined, + location: Node, sourceFile: SourceFile, typeChecker: TypeChecker, target: ScriptTarget, log: Log, kind: CompletionKind, preferences: UserPreferences, + compilerOptions: CompilerOptions, + isTypeOnlyLocation?: boolean, propertyAccessToConvert?: PropertyAccessExpression, jsxIdentifierExpected?: boolean, isJsxInitializer?: IsJsxInitializer, + importCompletionNode?: Node, recommendedCompletion?: Symbol, symbolToOriginInfoMap?: SymbolOriginInfoMap, symbolToSortTextMap?: SymbolSortTextMap, ): UniqueNameSet { const start = timestamp(); + const variableDeclaration = getVariableDeclaration(location); + const useSemicolons = probablyUsesSemicolons(sourceFile); // Tracks unique names. // Value is set to false for global variables or completions from external module exports, because we can have multiple of those; // true otherwise. Based on the order we add things we will always see locals first, then globals, then module exports. // So adding a completion for a local will prevent us from adding completions for external module exports sharing the same name. const uniques = new Map(); - for (const symbol of symbols) { - const origin = symbolToOriginInfoMap ? symbolToOriginInfoMap[getSymbolId(symbol)] : undefined; + for (let i = 0; i < symbols.length; i++) { + const symbol = symbols[i]; + const origin = symbolToOriginInfoMap?.[i]; const info = getCompletionEntryDisplayNameForSymbol(symbol, target, origin, kind, !!jsxIdentifierExpected); - if (!info) { - continue; - } - const { name, needsConvertPropertyAccess } = info; - if (uniques.get(name)) { + if (!info || uniques.get(info.name) || kind === CompletionKind.Global && symbolToSortTextMap && !shouldIncludeSymbol(symbol, symbolToSortTextMap)) { continue; } + const { name, needsConvertPropertyAccess } = info; const entry = createCompletionEntry( symbol, symbolToSortTextMap && symbolToSortTextMap[getSymbolId(symbol)] || SortText.LocationPriority, @@ -576,6 +597,9 @@ namespace ts.Completions { recommendedCompletion, propertyAccessToConvert, isJsxInitializer, + importCompletionNode, + useSemicolons, + compilerOptions, preferences ); if (!entry) { @@ -583,9 +607,8 @@ namespace ts.Completions { } /** True for locals; false for globals, module exports from other files, `this.` completions. */ - const shouldShadowLaterSymbols = !origin && !(symbol.parent === undefined && !some(symbol.declarations, d => d.getSourceFile() === location!.getSourceFile())); + const shouldShadowLaterSymbols = !origin && !(symbol.parent === undefined && !some(symbol.declarations, d => d.getSourceFile() === location.getSourceFile())); uniques.set(name, shouldShadowLaterSymbols); - entries.push(entry); } @@ -598,8 +621,55 @@ namespace ts.Completions { has: name => uniques.has(name), add: name => uniques.set(name, true), }; + + function shouldIncludeSymbol(symbol: Symbol, symbolToSortTextMap: SymbolSortTextMap): boolean { + if (!isSourceFile(location)) { + // export = /**/ here we want to get all meanings, so any symbol is ok + if (isExportAssignment(location.parent)) { + return true; + } + // Filter out variables from their own initializers + // `const a = /* no 'a' here */` + if (variableDeclaration && symbol.valueDeclaration === variableDeclaration) { + return false; + } + + // External modules can have global export declarations that will be + // available as global keywords in all scopes. But if the external module + // already has an explicit export and user only wants to user explicit + // module imports then the global keywords will be filtered out so auto + // import suggestions will win in the completion + const symbolOrigin = skipAlias(symbol, typeChecker); + // We only want to filter out the global keywords + // Auto Imports are not available for scripts so this conditional is always false + if (!!sourceFile.externalModuleIndicator + && !compilerOptions.allowUmdGlobalAccess + && symbolToSortTextMap[getSymbolId(symbol)] === SortText.GlobalsOrKeywords + && (symbolToSortTextMap[getSymbolId(symbolOrigin)] === SortText.AutoImportSuggestions + || symbolToSortTextMap[getSymbolId(symbolOrigin)] === SortText.LocationPriority)) { + return false; + } + // Continue with origin symbol + symbol = symbolOrigin; + + // import m = /**/ <-- It can only access namespace (if typing import = x. this would get member symbols and not namespace) + if (isInRightSideOfInternalImportEqualsDeclaration(location)) { + return !!(symbol.flags & SymbolFlags.Namespace); + } + + if (isTypeOnlyLocation) { + // It's a type, but you can reach it by namespace.type as well + return symbolCanBeReferencedAtTypeLocation(symbol, typeChecker); + } + } + + // expressions are value space (which includes the value namespaces) + return !!(getCombinedLocalAndExportSymbolFlags(symbol) & SymbolFlags.Value); + } } + + function getLabelCompletionAtPosition(node: BreakOrContinueStatement): CompletionInfo | undefined { const entries = getLabelStatementCompletions(node); if (entries.length) { @@ -636,8 +706,8 @@ namespace ts.Completions { interface SymbolCompletion { type: "symbol"; symbol: Symbol; - location: Node | undefined; - symbolToOriginInfoMap: SymbolOriginInfoMap; + location: Node; + origin: SymbolOriginInfo | SymbolOriginInfoExport | SymbolOriginInfoResolvedExport | undefined; previousToken: Node | undefined; readonly isJsxInitializer: IsJsxInitializer; readonly isTypeOnlyLocation: boolean; @@ -651,6 +721,21 @@ namespace ts.Completions { host: LanguageServiceHost, preferences: UserPreferences, ): SymbolCompletion | { type: "request", request: Request } | { type: "literal", literal: string | number | PseudoBigInt } | { type: "none" } { + if (entryId.data) { + const autoImport = getAutoImportSymbolFromCompletionEntryData(entryId.name, entryId.data, program, host); + if (autoImport) { + return { + type: "symbol", + symbol: autoImport.symbol, + location: getTouchingPropertyName(sourceFile, position), + previousToken: findPrecedingToken(position, sourceFile, /*startNode*/ undefined)!, + isJsxInitializer: false, + isTypeOnlyLocation: false, + origin: autoImport.origin, + }; + } + } + const compilerOptions = program.getCompilerOptions(); const completionData = getCompletionData(program, log, sourceFile, isUncheckedFile(sourceFile, compilerOptions), position, { includeCompletionsForModuleExports: true, includeCompletionsWithInsertText: true }, entryId, host); if (!completionData) { @@ -669,11 +754,11 @@ namespace ts.Completions { // 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 // completion entry. - return firstDefined(symbols, (symbol): SymbolCompletion | undefined => { - const origin = symbolToOriginInfoMap[getSymbolId(symbol)]; + return firstDefined(symbols, (symbol, index): SymbolCompletion | undefined => { + const origin = symbolToOriginInfoMap[index]; const info = getCompletionEntryDisplayNameForSymbol(symbol, compilerOptions.target!, origin, completionKind, completionData.isJsxIdentifierExpected); return info && info.name === entryId.name && getSourceFromOrigin(origin) === entryId.source - ? { type: "symbol" as const, symbol, location, symbolToOriginInfoMap, previousToken, isJsxInitializer, isTypeOnlyLocation } + ? { type: "symbol" as const, symbol, location, origin, previousToken, isJsxInitializer, isTypeOnlyLocation } : undefined; }) || { type: "none" }; } @@ -721,9 +806,9 @@ namespace ts.Completions { } } case "symbol": { - const { symbol, location, symbolToOriginInfoMap, previousToken } = symbolCompletion; - const { codeActions, sourceDisplay } = getCompletionEntryCodeActionsAndSourceDisplay(symbolToOriginInfoMap, symbol, program, typeChecker, host, compilerOptions, sourceFile, position, previousToken, formatContext, preferences); - return createCompletionDetailsForSymbol(symbol, typeChecker, sourceFile, location!, cancellationToken, codeActions, sourceDisplay); // TODO: GH#18217 + const { symbol, location, origin, previousToken } = symbolCompletion; + const { codeActions, sourceDisplay } = getCompletionEntryCodeActionsAndSourceDisplay(origin, symbol, program, typeChecker, host, compilerOptions, sourceFile, position, previousToken, formatContext, preferences, entryId.data); + return createCompletionDetailsForSymbol(symbol, typeChecker, sourceFile, location, cancellationToken, codeActions, sourceDisplay); // TODO: GH#18217 } case "literal": { const { literal } = symbolCompletion; @@ -750,7 +835,7 @@ namespace ts.Completions { } export function createCompletionDetails(name: string, kindModifiers: string, kind: ScriptElementKind, displayParts: SymbolDisplayPart[], documentation?: SymbolDisplayPart[], tags?: JSDocTagInfo[], codeActions?: CodeAction[], source?: SymbolDisplayPart[]): CompletionEntryDetails { - return { name, kindModifiers, kind, displayParts, documentation, tags, codeActions, source }; + return { name, kindModifiers, kind, displayParts, documentation, tags, codeActions, source, sourceDisplay: source }; } interface CodeActionsAndSourceDisplay { @@ -758,7 +843,7 @@ namespace ts.Completions { readonly sourceDisplay: SymbolDisplayPart[] | undefined; } function getCompletionEntryCodeActionsAndSourceDisplay( - symbolToOriginInfoMap: SymbolOriginInfoMap, + origin: SymbolOriginInfo | SymbolOriginInfoExport | SymbolOriginInfoResolvedExport | undefined, symbol: Symbol, program: Program, checker: TypeChecker, @@ -769,13 +854,17 @@ namespace ts.Completions { previousToken: Node | undefined, formatContext: formatting.FormatContext, preferences: UserPreferences, + data: CompletionEntryData | undefined, ): CodeActionsAndSourceDisplay { - const symbolOriginInfo = symbolToOriginInfoMap[getSymbolId(symbol)]; - if (!symbolOriginInfo || !originIsExport(symbolOriginInfo)) { + if (data?.moduleSpecifier) { + return { codeActions: undefined, sourceDisplay: [textPart(data.moduleSpecifier)] }; + } + + if (!origin || !originIsExport(origin)) { return { codeActions: undefined, sourceDisplay: undefined }; } - const { moduleSymbol } = symbolOriginInfo; + const { moduleSymbol } = origin; const exportedSymbol = checker.getMergedSymbol(skipAlias(symbol.exportSymbol || symbol, checker)); const { moduleSpecifier, codeAction } = codefix.getImportCompletionAction( exportedSymbol, @@ -814,7 +903,7 @@ namespace ts.Completions { /** Note that the presence of this alone doesn't mean that we need a conversion. Only do that if the completion is not an ordinary identifier. */ readonly propertyAccessToConvert: PropertyAccessExpression | undefined; readonly isNewIdentifierLocation: boolean; - readonly location: Node | undefined; + readonly location: Node; readonly keywordFilters: KeywordCompletionFilters; readonly literals: readonly (string | number | PseudoBigInt)[]; readonly symbolToOriginInfoMap: SymbolOriginInfoMap; @@ -826,6 +915,7 @@ namespace ts.Completions { readonly isTypeOnlyLocation: boolean; /** In JSX tag name and attribute names, identifiers like "my-tag" or "aria-name" is valid identifier. */ readonly isJsxIdentifierExpected: boolean; + readonly importCompletionNode?: Node; } type Request = { readonly kind: CompletionDataKind.JsDocTagName | CompletionDataKind.JsDocTag } | { readonly kind: CompletionDataKind.JsDocParameterName, tag: JSDocParameterTag }; @@ -899,12 +989,11 @@ namespace ts.Completions { sourceFile: SourceFile, isUncheckedFile: boolean, position: number, - preferences: Pick, + preferences: UserPreferences, detailsEntryId: CompletionEntryIdentifier | undefined, host: LanguageServiceHost ): CompletionData | Request | undefined { const typeChecker = program.getTypeChecker(); - const compilerOptions = program.getCompilerOptions(); let start = timestamp(); let currentToken = getTokenAtPosition(sourceFile, position); // TODO: GH#15853 @@ -1007,11 +1096,19 @@ namespace ts.Completions { let isStartingCloseTag = false; let isJsxInitializer: IsJsxInitializer = false; let isJsxIdentifierExpected = false; - + let importCompletionNode: Node | undefined; let location = getTouchingPropertyName(sourceFile, position); + if (contextToken) { + // 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 = preferences.includeCompletionsForImportStatements && preferences.includeCompletionsWithInsertText + ? getImportCompletionNode(contextToken) + : undefined; // Bail out if this is a known invalid completion location - if (isCompletionListBlocker(contextToken)) { + if (!importCompletionNode && isCompletionListBlocker(contextToken)) { log("Returning an empty list because completion was requested in an invalid position."); return undefined; } @@ -1050,7 +1147,7 @@ namespace ts.Completions { return undefined; } } - else if (sourceFile.languageVariant === LanguageVariant.JSX) { + else if (!importCompletionNode && sourceFile.languageVariant === LanguageVariant.JSX) { // // 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. @@ -1142,8 +1239,11 @@ namespace ts.Completions { let symbols: Symbol[] = []; const symbolToOriginInfoMap: SymbolOriginInfoMap = []; const symbolToSortTextMap: SymbolSortTextMap = []; - const importSuggestionsCache = host.getImportSuggestionsCache && host.getImportSuggestionsCache(); + const seenPropertySymbols = new Map(); const isTypeOnly = isTypeOnlyCompletion(); + const getModuleSpecifierResolutionHost = memoizeOne((isFromPackageJson: boolean) => { + return createModuleSpecifierResolutionHost(isFromPackageJson ? host.getPackageJsonAutoImportProvider!()! : program, host); + }); if (isRightOfDot || isRightOfQuestionDot) { getTypeScriptMemberSymbols(); @@ -1153,7 +1253,7 @@ namespace ts.Completions { Debug.assertEachIsDefined(tagSymbols, "getJsxIntrinsicTagNames() should all be defined"); tryGetGlobalSymbols(); symbols = tagSymbols.concat(symbols); - completionKind = CompletionKind.MemberLike; + completionKind = CompletionKind.Global; keywordFilters = KeywordCompletionFilters.None; } else if (isStartingCloseTag) { @@ -1162,7 +1262,7 @@ namespace ts.Completions { if (tagSymbol) { symbols = [tagSymbol]; } - completionKind = CompletionKind.MemberLike; + completionKind = CompletionKind.Global; keywordFilters = KeywordCompletionFilters.None; } else { @@ -1197,6 +1297,7 @@ namespace ts.Completions { symbolToSortTextMap, isTypeOnlyLocation: isTypeOnly, isJsxIdentifierExpected, + importCompletionNode, }; type JSDocTagWithTypeExpression = JSDocParameterTag | JSDocPropertyTag | JSDocReturnTag | JSDocTypeTag | JSDocTypedefTag; @@ -1236,7 +1337,7 @@ namespace ts.Completions { const exportedSymbols = typeChecker.getExportsOfModule(symbol); Debug.assertEachIsDefined(exportedSymbols, "getExportsOfModule() should all be defined"); const isValidValueAccess = (symbol: Symbol) => typeChecker.isValidPropertyAccess(isImportType ? node : (node.parent), symbol.name); - const isValidTypeAccess = (symbol: Symbol) => symbolCanBeReferencedAtTypeLocation(symbol); + const isValidTypeAccess = (symbol: Symbol) => symbolCanBeReferencedAtTypeLocation(symbol, typeChecker); const isValidAccess: (symbol: Symbol) => boolean = isNamespaceName // At `namespace N.M/**/`, if this is the only declaration of `M`, don't include `M` as a completion. @@ -1349,13 +1450,24 @@ namespace ts.Completions { const nameSymbol = leftMostName && typeChecker.getSymbolAtLocation(leftMostName); // If this is nested like for `namespace N { export const sym = Symbol(); }`, we'll add the completion for `N`. const firstAccessibleSymbol = nameSymbol && getFirstSymbolInChain(nameSymbol, contextToken, typeChecker); - if (firstAccessibleSymbol && !symbolToOriginInfoMap[getSymbolId(firstAccessibleSymbol)]) { + if (firstAccessibleSymbol && addToSeen(seenPropertySymbols, getSymbolId(firstAccessibleSymbol))) { + const index = symbols.length; symbols.push(firstAccessibleSymbol); const moduleSymbol = firstAccessibleSymbol.parent; - symbolToOriginInfoMap[getSymbolId(firstAccessibleSymbol)] = - !moduleSymbol || !isExternalModuleSymbol(moduleSymbol) - ? { kind: getNullableSymbolOriginInfoKind(SymbolOriginInfoKind.SymbolMemberNoExport) } - : { kind: getNullableSymbolOriginInfoKind(SymbolOriginInfoKind.SymbolMemberExport), moduleSymbol, isDefaultExport: false }; + if (!moduleSymbol || !isExternalModuleSymbol(moduleSymbol)) { + symbolToOriginInfoMap[index] = { kind: getNullableSymbolOriginInfoKind(SymbolOriginInfoKind.SymbolMemberNoExport) }; + } + else { + const origin: SymbolOriginInfoExport = { + kind: getNullableSymbolOriginInfoKind(SymbolOriginInfoKind.SymbolMemberExport), + moduleSymbol, + isDefaultExport: false, + symbolName: firstAccessibleSymbol.name, + exportName: firstAccessibleSymbol.name, + fileName: isExternalModuleNameRelative(stripQuotes(moduleSymbol.name)) ? cast(moduleSymbol.valueDeclaration, isSourceFile).fileName : undefined, + }; + symbolToOriginInfoMap[index] = origin; + } } else if (preferences.includeCompletionsWithInsertText) { addSymbolOriginInfo(symbol); @@ -1377,11 +1489,11 @@ namespace ts.Completions { function addSymbolOriginInfo(symbol: Symbol) { if (preferences.includeCompletionsWithInsertText) { - if (insertAwait && !symbolToOriginInfoMap[getSymbolId(symbol)]) { - symbolToOriginInfoMap[getSymbolId(symbol)] = { kind: getNullableSymbolOriginInfoKind(SymbolOriginInfoKind.Promise) }; + if (insertAwait && addToSeen(seenPropertySymbols, getSymbolId(symbol))) { + symbolToOriginInfoMap[symbols.length] = { kind: getNullableSymbolOriginInfoKind(SymbolOriginInfoKind.Promise) }; } else if (insertQuestionDot) { - symbolToOriginInfoMap[getSymbolId(symbol)] = { kind: SymbolOriginInfoKind.Nullable }; + symbolToOriginInfoMap[symbols.length] = { kind: SymbolOriginInfoKind.Nullable }; } } } @@ -1398,6 +1510,7 @@ namespace ts.Completions { function tryGetGlobalSymbols(): boolean { const result: GlobalsSearch = tryGetObjectLikeCompletionSymbols() + || tryGetImportCompletionSymbols() || tryGetImportOrExportClauseCompletionSymbols() || tryGetLocalNamedExportCompletionSymbols() || tryGetConstructorCompletion() @@ -1431,6 +1544,12 @@ namespace ts.Completions { return GlobalsSearch.Success; } + function tryGetImportCompletionSymbols(): GlobalsSearch { + if (!importCompletionNode) return GlobalsSearch.Continue; + collectAutoImports(/*resolveModuleSpecifiers*/ true); + return GlobalsSearch.Success; + } + function getGlobalCompletions(): void { keywordFilters = tryGetFunctionLikeBodyCompletionContainer(contextToken) ? KeywordCompletionFilters.FunctionLikeBodyKeywords : KeywordCompletionFilters.All; @@ -1489,49 +1608,23 @@ namespace ts.Completions { const thisType = typeChecker.tryGetThisTypeAt(scopeNode, /*includeGlobalThis*/ false); if (thisType && !isProbablyGlobalType(thisType, sourceFile, typeChecker)) { for (const symbol of getPropertiesForCompletion(thisType, typeChecker)) { - symbolToOriginInfoMap[getSymbolId(symbol)] = { kind: SymbolOriginInfoKind.ThisType }; + symbolToOriginInfoMap[symbols.length] = { kind: SymbolOriginInfoKind.ThisType }; symbols.push(symbol); symbolToSortTextMap[getSymbolId(symbol)] = SortText.SuggestedClassMembers; } } } - - if (shouldOfferImportCompletions()) { - const lowerCaseTokenText = previousToken && isIdentifier(previousToken) ? previousToken.text.toLowerCase() : ""; - if (detailsEntryId?.data) { - const autoImport = getAutoImportSymbolFromCompletionEntryData(detailsEntryId.data); - if (autoImport) { - const symbolId = getSymbolId(autoImport.symbol); - symbols.push(autoImport.symbol); - symbolToOriginInfoMap[symbolId] = autoImport.origin; - } - } - else { - const autoImportSuggestions = getSymbolsFromOtherSourceFileExports(program.getCompilerOptions().target!, host); - if (!detailsEntryId && importSuggestionsCache) { - importSuggestionsCache.set(sourceFile.fileName, autoImportSuggestions, host.getProjectVersion && host.getProjectVersion()); - } - autoImportSuggestions.forEach(({ symbol, symbolName, origin }) => { - if (detailsEntryId) { - if (detailsEntryId.source && stripQuotes(origin.moduleSymbol.name) !== detailsEntryId.source) { - return; - } - } - else if (!stringContainsCharactersInOrder(symbolName.toLowerCase(), lowerCaseTokenText)) { - return; - } - - const symbolId = getSymbolId(symbol); - symbols.push(symbol); - symbolToOriginInfoMap[symbolId] = origin; - symbolToSortTextMap[symbolId] = SortText.AutoImportSuggestions; - }); - } + collectAutoImports(/*resolveModuleSpecifier*/ false); + if (isTypeOnly) { + keywordFilters = contextToken && isAssertionExpression(contextToken.parent) + ? KeywordCompletionFilters.TypeAssertionKeywords + : KeywordCompletionFilters.TypeKeywords; } - filterGlobalCompletion(symbols); } function shouldOfferImportCompletions(): boolean { + // If already typing an import statement, provide completions for it. + if (importCompletionNode) 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. @@ -1556,75 +1649,6 @@ namespace ts.Completions { } } - function filterGlobalCompletion(symbols: Symbol[]): void { - const isTypeOnly = isTypeOnlyCompletion(); - if (isTypeOnly) { - keywordFilters = contextToken && isAssertionExpression(contextToken.parent) - ? KeywordCompletionFilters.TypeAssertionKeywords - : KeywordCompletionFilters.TypeKeywords; - } - - const variableDeclaration = getVariableDeclaration(location); - - filterMutate(symbols, symbol => { - if (!isSourceFile(location)) { - // export = /**/ here we want to get all meanings, so any symbol is ok - if (isExportAssignment(location.parent)) { - return true; - } - - // Filter out variables from their own initializers - // `const a = /* no 'a' here */` - if (variableDeclaration && symbol.valueDeclaration === variableDeclaration) { - return false; - } - - // External modules can have global export declarations that will be - // available as global keywords in all scopes. But if the external module - // already has an explicit export and user only wants to user explicit - // module imports then the global keywords will be filtered out so auto - // import suggestions will win in the completion - const symbolOrigin = skipAlias(symbol, typeChecker); - // We only want to filter out the global keywords - // Auto Imports are not available for scripts so this conditional is always false - if (!!sourceFile.externalModuleIndicator - && !compilerOptions.allowUmdGlobalAccess - && symbolToSortTextMap[getSymbolId(symbol)] === SortText.GlobalsOrKeywords - && symbolToSortTextMap[getSymbolId(symbolOrigin)] === SortText.AutoImportSuggestions) { - return false; - } - // Continue with origin symbol - symbol = symbolOrigin; - - // import m = /**/ <-- It can only access namespace (if typing import = x. this would get member symbols and not namespace) - if (isInRightSideOfInternalImportEqualsDeclaration(location)) { - return !!(symbol.flags & SymbolFlags.Namespace); - } - - if (isTypeOnly) { - // It's a type, but you can reach it by namespace.type as well - return symbolCanBeReferencedAtTypeLocation(symbol); - } - } - - // expressions are value space (which includes the value namespaces) - return !!(getCombinedLocalAndExportSymbolFlags(symbol) & SymbolFlags.Value); - }); - } - - function getVariableDeclaration(property: Node): VariableDeclaration | undefined { - const variableDeclaration = findAncestor(property, node => - isFunctionBlock(node) || isArrowFunctionBody(node) || isBindingPattern(node) - ? "quit" - : isVariableDeclaration(node)); - - return variableDeclaration as VariableDeclaration | undefined; - } - - function isArrowFunctionBody(node: Node) { - return node.parent && isArrowFunction(node.parent) && node.parent.body === node; - }; - function isTypeOnlyCompletion(): boolean { return insideJsDocTagTypeExpression || !isContextTokenValueLocation(contextToken) && @@ -1667,134 +1691,84 @@ namespace ts.Completions { return false; } - /** True if symbol is a type or a module containing at least one type. */ - function symbolCanBeReferencedAtTypeLocation(symbol: Symbol, seenModules = new Map()): boolean { - const sym = skipAlias(symbol.exportSymbol || symbol, typeChecker); - return !!(sym.flags & SymbolFlags.Type) || - !!(sym.flags & SymbolFlags.Module) && - addToSeen(seenModules, getSymbolId(sym)) && - typeChecker.getExportsOfModule(sym).some(e => symbolCanBeReferencedAtTypeLocation(e, seenModules)); - } - - /** - * Gathers symbols that can be imported from other files, de-duplicating along the way. Symbols can be "duplicates" - * if re-exported from another module by the same name, e.g. `export { foo } from "./a"`. - */ - function getSymbolsFromOtherSourceFileExports(target: ScriptTarget, host: LanguageServiceHost): readonly AutoImportSuggestion[] { - const cached = importSuggestionsCache && importSuggestionsCache.get( - sourceFile.fileName, - typeChecker, - detailsEntryId && host.getProjectVersion ? host.getProjectVersion() : undefined); - - if (cached) { - log("getSymbolsFromOtherSourceFileExports: Using cached list"); - return cached; - } - - const startTime = timestamp(); - log(`getSymbolsFromOtherSourceFileExports: Recomputing list${detailsEntryId ? " for details entry" : ""}`); - const seenResolvedModules = new Map(); - const results = createMultiMap(); - - codefix.forEachExternalModuleToImportFrom(program, host, sourceFile, !detailsEntryId, /*useAutoImportProvider*/ true, (moduleSymbol, file, program, isFromPackageJson) => { - // Perf -- ignore other modules if this is a request for details - if (detailsEntryId && detailsEntryId.source && stripQuotes(moduleSymbol.name) !== detailsEntryId.source) { - return; - } - - const typeChecker = program.getTypeChecker(); - const resolvedModuleSymbol = typeChecker.resolveExternalModuleSymbol(moduleSymbol); - // resolvedModuleSymbol may be a namespace. A namespace may be `export =` by multiple module declarations, but only keep the first one. - if (!addToSeen(seenResolvedModules, getSymbolId(resolvedModuleSymbol))) { - return; - } - - // Don't add another completion for `export =` of a symbol that's already global. - // So in `declare namespace foo {} declare module "foo" { export = foo; }`, there will just be the global completion for `foo`. - if (resolvedModuleSymbol !== moduleSymbol && every(resolvedModuleSymbol.declarations, isNonGlobalDeclaration)) { - pushSymbol(resolvedModuleSymbol, InternalSymbolName.ExportEquals, moduleSymbol, file, isFromPackageJson); - } - - for (const symbol of typeChecker.getExportsAndPropertiesOfModule(moduleSymbol)) { - // If this is `export { _break as break };` (a keyword) -- skip this and prefer the keyword completion. - if (some(symbol.declarations, d => isExportSpecifier(d) && !!d.propertyName && isIdentifierANonContextualKeyword(d.name))) { - continue; - } - - pushSymbol(symbol, symbol.name, moduleSymbol, file, isFromPackageJson); + /** Mutates `symbols`, `symbolToOriginInfoMap`, and `symbolToSortTextMap` */ + function collectAutoImports(resolveModuleSpecifiers: boolean) { + if (!shouldOfferImportCompletions()) return; + Debug.assert(!detailsEntryId?.data); + const start = timestamp(); + const moduleSpecifierCache = host.getModuleSpecifierCache?.(); + host.log?.(`collectAutoImports: starting, ${resolveModuleSpecifiers ? "" : "not "}resolving module specifiers`); + if (moduleSpecifierCache) { + host.log?.(`collectAutoImports: module specifier cache size: ${moduleSpecifierCache.count()}`); + } + const lowerCaseTokenText = previousToken && isIdentifier(previousToken) ? previousToken.text.toLowerCase() : ""; + const exportInfo = codefix.getSymbolToExportInfoMap(sourceFile, host, program); + const packageJsonAutoImportProvider = host.getPackageJsonAutoImportProvider?.(); + const packageJsonFilter = detailsEntryId ? undefined : createPackageJsonImportFilter(sourceFile, host); + exportInfo.forEach((info, key) => { + const symbolName = key.substring(0, key.indexOf("|")); + if (!detailsEntryId && isStringANonContextualKeyword(symbolName)) return; + const isCompletionDetailsMatch = detailsEntryId && some(info, i => detailsEntryId.source === stripQuotes(i.moduleSymbol.name)); + if (isCompletionDetailsMatch || isNameMatch(symbolName)) { + // If we don't need to resolve module specifiers, we can use any re-export that is importable at all + // (We need to ensure that at least one is importable to show a completion.) + const { moduleSpecifier, exportInfo } = resolveModuleSpecifiers + ? codefix.getModuleSpecifierForBestExportInfo(info, sourceFile, program, host, preferences) + : { moduleSpecifier: undefined, exportInfo: find(info, isImportableExportInfo) }; + if (!exportInfo) return; + const moduleFile = tryCast(exportInfo.moduleSymbol.valueDeclaration, isSourceFile); + const isDefaultExport = exportInfo.exportKind === ExportKind.Default; + const symbol = isDefaultExport && getLocalSymbolForExportDefault(exportInfo.symbol) || exportInfo.symbol; + pushAutoImportSymbol(symbol, { + kind: resolveModuleSpecifiers ? SymbolOriginInfoKind.ResolvedExport : SymbolOriginInfoKind.Export, + moduleSpecifier, + symbolName, + exportName: exportInfo.exportKind === ExportKind.ExportEquals ? InternalSymbolName.ExportEquals : exportInfo.symbol.name, + fileName: moduleFile?.fileName, + isDefaultExport, + moduleSymbol: exportInfo.moduleSymbol, + isFromPackageJson: exportInfo.isFromPackageJson, + }); } }); + host.log?.(`collectAutoImports: done in ${timestamp() - start} ms`); - log(`getSymbolsFromOtherSourceFileExports: ${timestamp() - startTime}`); - return flatten(arrayFrom(results.values())); - - function pushSymbol(symbol: Symbol, exportName: string, moduleSymbol: Symbol, file: SourceFile | undefined, isFromPackageJson: boolean) { - const isDefaultExport = symbol.escapedName === InternalSymbolName.Default; - const nonLocalSymbol = symbol; - if (isDefaultExport) { - symbol = getLocalSymbolForExportDefault(symbol) || symbol; - } - if (typeChecker.isUndefinedSymbol(symbol)) { - return; - } - const original = skipAlias(nonLocalSymbol, typeChecker); - const symbolName = getNameForExportedSymbol(symbol, target); - const existingSuggestions = results.get(getSymbolId(original)); - if (!some(existingSuggestions, s => s.symbolName === symbolName && moduleSymbolsAreDuplicateOrigins(moduleSymbol, s.origin.moduleSymbol))) { - const origin: SymbolOriginInfoExport = { - kind: SymbolOriginInfoKind.Export, - moduleSymbol, - isDefaultExport, - isFromPackageJson, - exportName, - fileName: file?.fileName - }; - results.add(getSymbolId(original), { - symbol, - symbolName, - origin, - }); + function isNameMatch(symbolName: string) { + const lowerCaseSymbolName = symbolName.toLowerCase(); + if (resolveModuleSpecifiers && lowerCaseTokenText) { + // Use a more restrictive filter if resolving module specifiers since resolving module specifiers is expensive. + return lowerCaseTokenText[0] === lowerCaseSymbolName[0] && stringContainsCharactersInOrder(lowerCaseSymbolName, lowerCaseTokenText); } + return stringContainsCharactersInOrder(lowerCaseSymbolName, lowerCaseTokenText); } - } - function getAutoImportSymbolFromCompletionEntryData(data: CompletionEntryData): { symbol: Symbol, origin: SymbolOriginInfoExport } | undefined { - const containingProgram = data.isPackageJsonImport ? host.getPackageJsonAutoImportProvider!()! : program; - const checker = containingProgram.getTypeChecker(); - const moduleSymbol = - data.ambientModuleName ? checker.tryFindAmbientModule(data.ambientModuleName) : - data.fileName ? checker.getMergedSymbol(Debug.checkDefined(containingProgram.getSourceFile(data.fileName)).symbol) : - undefined; - - if (!moduleSymbol) return undefined; - let symbol = data.exportName === InternalSymbolName.ExportEquals - ? checker.resolveExternalModuleSymbol(moduleSymbol) - : checker.tryGetMemberInModuleExportsAndProperties(data.exportName, moduleSymbol); - if (!symbol) return undefined; - const isDefaultExport = data.exportName === InternalSymbolName.Default; - symbol = isDefaultExport && getLocalSymbolForExportDefault(symbol) || symbol; - return { - symbol, - origin: { - kind: SymbolOriginInfoKind.Export, - moduleSymbol, - isDefaultExport, - exportName: data.exportName, - fileName: data.fileName, + function isImportableExportInfo(info: SymbolExportInfo) { + const moduleFile = tryCast(info.moduleSymbol.valueDeclaration, isSourceFile); + if (!moduleFile) { + return packageJsonFilter + ? packageJsonFilter.allowsImportingAmbientModule(info.moduleSymbol, getModuleSpecifierResolutionHost(info.isFromPackageJson)) + : true; } - }; + return isImportableFile( + info.isFromPackageJson ? packageJsonAutoImportProvider! : program, + sourceFile, + moduleFile, + packageJsonFilter, + getModuleSpecifierResolutionHost(info.isFromPackageJson), + moduleSpecifierCache); + } } - /** - * Determines whether a module symbol is redundant with another for purposes of offering - * auto-import completions for exports of the same symbol. Exports of the same symbol - * will not be offered from different external modules, but they will be offered from - * different ambient modules. - */ - function moduleSymbolsAreDuplicateOrigins(a: Symbol, b: Symbol) { - const ambientNameA = pathIsBareSpecifier(stripQuotes(a.name)) ? a.name : undefined; - const ambientNameB = pathIsBareSpecifier(stripQuotes(b.name)) ? b.name : undefined; - return ambientNameA === ambientNameB; + + function pushAutoImportSymbol(symbol: Symbol, origin: SymbolOriginInfoResolvedExport | SymbolOriginInfoExport) { + const symbolId = getSymbolId(symbol); + if (symbolToSortTextMap[symbolId] === SortText.GlobalsOrKeywords) { + // If an auto-importable symbol is available as a global, don't add the auto import + return; + } + symbolToOriginInfoMap[symbols.length] = origin; + symbolToSortTextMap[symbolId] = importCompletionNode ? SortText.LocationPriority : SortText.AutoImportSuggestions; + symbols.push(symbol); } /** @@ -1808,7 +1782,8 @@ namespace ts.Completions { } let characterIndex = 0; - for (let strIndex = 0; strIndex < str.length; strIndex++) { + const len = str.length; + for (let strIndex = 0; strIndex < len; strIndex++) { if (str.charCodeAt(strIndex) === characters.charCodeAt(characterIndex)) { characterIndex++; if (characterIndex === characters.length) { @@ -2587,6 +2562,34 @@ namespace ts.Completions { } } + function getAutoImportSymbolFromCompletionEntryData(name: string, data: CompletionEntryData, program: Program, host: LanguageServiceHost): { symbol: Symbol, origin: SymbolOriginInfoExport } | undefined { + const containingProgram = data.isPackageJsonImport ? host.getPackageJsonAutoImportProvider!()! : program; + const checker = containingProgram.getTypeChecker(); + const moduleSymbol = + data.ambientModuleName ? checker.tryFindAmbientModule(data.ambientModuleName) : + data.fileName ? checker.getMergedSymbol(Debug.checkDefined(containingProgram.getSourceFile(data.fileName)).symbol) : + undefined; + + if (!moduleSymbol) return undefined; + let symbol = data.exportName === InternalSymbolName.ExportEquals + ? checker.resolveExternalModuleSymbol(moduleSymbol) + : checker.tryGetMemberInModuleExportsAndProperties(data.exportName, moduleSymbol); + if (!symbol) return undefined; + const isDefaultExport = data.exportName === InternalSymbolName.Default; + symbol = isDefaultExport && getLocalSymbolForExportDefault(symbol) || symbol; + return { + symbol, + origin: { + kind: data.moduleSpecifier ? SymbolOriginInfoKind.ResolvedExport : SymbolOriginInfoKind.Export, + moduleSymbol, + symbolName: name, + isDefaultExport, + exportName: data.exportName, + fileName: data.fileName, + } + }; + } + interface CompletionEntryDisplayNameForSymbol { readonly name: string; readonly needsConvertPropertyAccess: boolean; @@ -2598,7 +2601,7 @@ namespace ts.Completions { kind: CompletionKind, jsxIdentifierExpected: boolean, ): CompletionEntryDisplayNameForSymbol | undefined { - const name = originIsExport(origin) ? getNameForExportedSymbol(symbol, target) : symbol.name; + const name = originIncludesSymbolName(origin) ? origin.symbolName : symbol.name; if (name === undefined // If the symbol is external module, don't show it in the completion list // (i.e declare module "http" { const x; } | // <= request completion here, "http" should not be there) @@ -2872,6 +2875,8 @@ namespace ts.Completions { return !!contextToken && (isStringLiteralLike(contextToken) ? !!tryGetImportFromModuleSpecifier(contextToken) : contextToken.kind === SyntaxKind.SlashToken && isJsxClosingElement(contextToken.parent)); + case " ": + return !!contextToken && isImportKeyword(contextToken) && contextToken.parent.kind === SyntaxKind.SourceFile; default: return Debug.assertNever(triggerCharacter); } @@ -2914,4 +2919,57 @@ namespace ts.Completions { } return undefined; } + + function getImportCompletionNode(contextToken: Node) { + const candidate = getCandidate(); + return candidate && rangeIsOnSingleLine(candidate, candidate.getSourceFile()) ? candidate : undefined; + + function getCandidate() { + const parent = contextToken.parent; + if (isImportEqualsDeclaration(parent)) { + return isModuleSpecifierMissingOrEmpty(parent.moduleReference) ? parent : undefined; + } + if (isNamedImports(parent) || isNamespaceImport(parent)) { + return isModuleSpecifierMissingOrEmpty(parent.parent.parent.moduleSpecifier) && (isNamespaceImport(parent) || parent.elements.length < 2) && !parent.parent.name + ? parent.parent.parent + : undefined; + } + if (isImportKeyword(contextToken) && isSourceFile(parent)) { + // A lone import keyword with nothing following it does not parse as a statement at all + return contextToken as Token; + } + if (isImportKeyword(contextToken) && isImportDeclaration(parent)) { + // `import s| from` + return isModuleSpecifierMissingOrEmpty(parent.moduleSpecifier) ? parent : undefined; + } + return undefined; + } + } + + function isModuleSpecifierMissingOrEmpty(specifier: ModuleReference | Expression) { + if (nodeIsMissing(specifier)) return true; + return !tryCast(isExternalModuleReference(specifier) ? specifier.expression : specifier, isStringLiteralLike)?.text; + } + + function getVariableDeclaration(property: Node): VariableDeclaration | undefined { + const variableDeclaration = findAncestor(property, node => + isFunctionBlock(node) || isArrowFunctionBody(node) || isBindingPattern(node) + ? "quit" + : isVariableDeclaration(node)); + + return variableDeclaration as VariableDeclaration | undefined; + } + + function isArrowFunctionBody(node: Node) { + return node.parent && isArrowFunction(node.parent) && node.parent.body === node; + }; + + /** True if symbol is a type or a module containing at least one type. */ + function symbolCanBeReferencedAtTypeLocation(symbol: Symbol, checker: TypeChecker, seenModules = new Map()): boolean { + const sym = skipAlias(symbol.exportSymbol || symbol, checker); + return !!(sym.flags & SymbolFlags.Type) || + !!(sym.flags & SymbolFlags.Module) && + addToSeen(seenModules, getSymbolId(sym)) && + checker.getExportsOfModule(sym).some(e => symbolCanBeReferencedAtTypeLocation(e, checker, seenModules)); + } } diff --git a/src/services/stringCompletions.ts b/src/services/stringCompletions.ts index 3eb0c20209808..3ae41525c58da 100644 --- a/src/services/stringCompletions.ts +++ b/src/services/stringCompletions.ts @@ -8,11 +8,11 @@ namespace ts.Completions.StringCompletions { if (isInString(sourceFile, position, contextToken)) { if (!contextToken || !isStringLiteralLike(contextToken)) return undefined; const entries = getStringLiteralCompletionEntries(sourceFile, contextToken, position, checker, options, host, preferences); - return convertStringLiteralCompletions(entries, contextToken, sourceFile, checker, log, preferences); + return convertStringLiteralCompletions(entries, contextToken, sourceFile, checker, log, options, preferences); } } - function convertStringLiteralCompletions(completion: StringLiteralCompletion | undefined, contextToken: StringLiteralLike, sourceFile: SourceFile, checker: TypeChecker, log: Log, preferences: UserPreferences): CompletionInfo | undefined { + function convertStringLiteralCompletions(completion: StringLiteralCompletion | undefined, contextToken: StringLiteralLike, sourceFile: SourceFile, checker: TypeChecker, log: Log, options: CompilerOptions, preferences: UserPreferences): CompletionInfo | undefined { if (completion === undefined) { return undefined; } @@ -33,7 +33,8 @@ namespace ts.Completions.StringCompletions { ScriptTarget.ESNext, log, CompletionKind.String, - preferences + preferences, + options, ); // Target will not be used, so arbitrary return { isGlobalCompletion: false, isMemberCompletion: true, isNewIdentifierLocation: completion.hasIndexSignature, optionalReplacementSpan, entries }; } diff --git a/src/services/types.ts b/src/services/types.ts index ada928c3bedce..0d50cab5e624c 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -305,7 +305,9 @@ namespace ts { /* @internal */ getPackageJsonsForAutoImport?(rootDir?: string): readonly PackageJsonInfo[]; /* @internal */ - getImportSuggestionsCache?(): Completions.ImportSuggestionsForFileCache; + getExportMapCache?(): ExportMapCache; + /* @internal */ + getModuleSpecifierCache?(): ModuleSpecifierCache; /* @internal */ setCompilerHost?(host: CompilerHost): void; /* @internal */ @@ -552,7 +554,7 @@ namespace ts { export type OrganizeImportsScope = CombinedCodeFixScope; - export type CompletionsTriggerCharacter = "." | '"' | "'" | "`" | "/" | "@" | "<" | "#"; + export type CompletionsTriggerCharacter = "." | '"' | "'" | "`" | "/" | "@" | "<" | "#" | " "; export interface GetCompletionsAtPositionOptions extends UserPreferences { /** @@ -1140,11 +1142,14 @@ namespace ts { * must be used to commit that completion entry. */ optionalReplacementSpan?: TextSpan; - /** * true when the current location also allows for a new identifier */ isNewIdentifierLocation: boolean; + /** + * Indicates to client to continue requesting completions on subsequent keystrokes. + */ + isIncomplete?: true; entries: CompletionEntry[]; } @@ -1160,6 +1165,10 @@ namespace ts { * in the case of InternalSymbolName.ExportEquals and InternalSymbolName.Default. */ exportName: string; + /** + * Set for auto imports with eagerly resolved module specifiers. + */ + moduleSpecifier?: string; } // see comments in protocol.ts @@ -1169,6 +1178,7 @@ namespace ts { kindModifiers?: string; // see ScriptElementKindModifier, comma separated sortText: string; insertText?: string; + isSnippet?: true; /** * An optional span that indicates the text to be replaced by this completion item. * If present, this span should be used instead of the default one. @@ -1177,6 +1187,7 @@ namespace ts { replacementSpan?: TextSpan; hasAction?: true; source?: string; + sourceDisplay?: SymbolDisplayPart[]; isRecommended?: true; isFromUncheckedFile?: true; isPackageJsonImport?: true; @@ -1199,7 +1210,9 @@ namespace ts { documentation?: SymbolDisplayPart[]; tags?: JSDocTagInfo[]; codeActions?: CodeAction[]; + /** @deprecated Use `sourceDisplay` instead. */ source?: SymbolDisplayPart[]; + sourceDisplay?: SymbolDisplayPart[]; } export interface OutliningSpan { diff --git a/src/services/utilities.ts b/src/services/utilities.ts index 3fb677efd00c6..d4662783d873b 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -1821,7 +1821,7 @@ namespace ts { } export function createModuleSpecifierResolutionHost(program: Program, host: LanguageServiceHost): ModuleSpecifierResolutionHost { - // Mix in `getProbableSymlinks` from Program when host doesn't have it + // Mix in `getSymlinkCache` from Program when host doesn't have it // in order for non-Project hosts to have a symlinks cache. return { fileExists: fileName => program.fileExists(fileName), @@ -1829,6 +1829,7 @@ namespace ts { readFile: maybeBind(host, host.readFile), useCaseSensitiveFileNames: maybeBind(host, host.useCaseSensitiveFileNames), getSymlinkCache: maybeBind(host, host.getSymlinkCache) || program.getSymlinkCache, + getModuleSpecifierCache: maybeBind(host, host.getModuleSpecifierCache), getGlobalTypingsCacheLocation: maybeBind(host, host.getGlobalTypingsCacheLocation), getSourceFiles: () => program.getSourceFiles(), redirectTargetsMap: program.redirectTargetsMap, @@ -2846,6 +2847,125 @@ namespace ts { } } + export interface PackageJsonImportFilter { + allowsImportingAmbientModule: (moduleSymbol: Symbol, moduleSpecifierResolutionHost: ModuleSpecifierResolutionHost) => boolean; + allowsImportingSourceFile: (sourceFile: SourceFile, moduleSpecifierResolutionHost: ModuleSpecifierResolutionHost) => boolean; + /** + * Use for a specific module specifier that has already been resolved. + * Use `allowsImportingAmbientModule` or `allowsImportingSourceFile` to resolve + * the best module specifier for a given module _and_ determine if it’s importable. + */ + allowsImportingSpecifier: (moduleSpecifier: string) => boolean; + } + + export function createPackageJsonImportFilter(fromFile: SourceFile, host: LanguageServiceHost): PackageJsonImportFilter { + const packageJsons = ( + (host.getPackageJsonsVisibleToFile && host.getPackageJsonsVisibleToFile(fromFile.fileName)) || getPackageJsonsVisibleToFile(fromFile.fileName, host) + ).filter(p => p.parseable); + + let usesNodeCoreModules: boolean | undefined; + return { allowsImportingAmbientModule, allowsImportingSourceFile, allowsImportingSpecifier }; + + function moduleSpecifierIsCoveredByPackageJson(specifier: string) { + const packageName = getNodeModuleRootSpecifier(specifier); + for (const packageJson of packageJsons) { + if (packageJson.has(packageName) || packageJson.has(getTypesPackageName(packageName))) { + return true; + } + } + return false; + } + + function allowsImportingAmbientModule(moduleSymbol: Symbol, moduleSpecifierResolutionHost: ModuleSpecifierResolutionHost): boolean { + if (!packageJsons.length || !moduleSymbol.valueDeclaration) { + return true; + } + + const declaringSourceFile = moduleSymbol.valueDeclaration.getSourceFile(); + const declaringNodeModuleName = getNodeModulesPackageNameFromFileName(declaringSourceFile.fileName, moduleSpecifierResolutionHost); + if (typeof declaringNodeModuleName === "undefined") { + return true; + } + + const declaredModuleSpecifier = stripQuotes(moduleSymbol.getName()); + if (isAllowedCoreNodeModulesImport(declaredModuleSpecifier)) { + return true; + } + + return moduleSpecifierIsCoveredByPackageJson(declaringNodeModuleName) + || moduleSpecifierIsCoveredByPackageJson(declaredModuleSpecifier); + } + + function allowsImportingSourceFile(sourceFile: SourceFile, moduleSpecifierResolutionHost: ModuleSpecifierResolutionHost): boolean { + if (!packageJsons.length) { + return true; + } + + const moduleSpecifier = getNodeModulesPackageNameFromFileName(sourceFile.fileName, moduleSpecifierResolutionHost); + if (!moduleSpecifier) { + return true; + } + + return moduleSpecifierIsCoveredByPackageJson(moduleSpecifier); + } + + function allowsImportingSpecifier(moduleSpecifier: string) { + if (!packageJsons.length || isAllowedCoreNodeModulesImport(moduleSpecifier)) { + return true; + } + if (pathIsRelative(moduleSpecifier) || isRootedDiskPath(moduleSpecifier)) { + return true; + } + return moduleSpecifierIsCoveredByPackageJson(moduleSpecifier); + } + + function isAllowedCoreNodeModulesImport(moduleSpecifier: string) { + // If we’re in JavaScript, it can be difficult to tell whether the user wants to import + // from Node core modules or not. We can start by seeing if the user is actually using + // any node core modules, as opposed to simply having @types/node accidentally as a + // dependency of a dependency. + if (isSourceFileJS(fromFile) && JsTyping.nodeCoreModules.has(moduleSpecifier)) { + if (usesNodeCoreModules === undefined) { + usesNodeCoreModules = consumesNodeCoreModules(fromFile); + } + if (usesNodeCoreModules) { + return true; + } + } + return false; + } + + function getNodeModulesPackageNameFromFileName(importedFileName: string, moduleSpecifierResolutionHost: ModuleSpecifierResolutionHost): string | undefined { + if (!stringContains(importedFileName, "node_modules")) { + return undefined; + } + const specifier = moduleSpecifiers.getNodeModulesPackageName( + host.getCompilationSettings(), + fromFile.path, + importedFileName, + moduleSpecifierResolutionHost, + ); + + if (!specifier) { + return undefined; + } + // Paths here are not node_modules, so we don’t care about them; + // returning anything will trigger a lookup in package.json. + if (!pathIsRelative(specifier) && !isRootedDiskPath(specifier)) { + return getNodeModuleRootSpecifier(specifier); + } + } + + function getNodeModuleRootSpecifier(fullSpecifier: string): string { + const components = getPathComponents(getPackageNameFromTypesPackageName(fullSpecifier)).slice(1); + // Scoped packages + if (startsWith(components[0], "@")) { + return `${components[0]}/${components[1]}`; + } + return components[0]; + } + } + function tryParseJson(text: string) { try { return JSON.parse(text); @@ -2994,5 +3114,211 @@ namespace ts { return isInJSFile(declaration) || !findAncestor(declaration, isGlobalScopeAugmentation); } + export const enum ImportKind { + Named, + Default, + Namespace, + CommonJS, + } + + export const enum ExportKind { + Named, + Default, + ExportEquals, + UMD, + } + + /** Information about how a symbol is exported from a module. */ + export interface SymbolExportInfo { + symbol: Symbol; + moduleSymbol: Symbol; + exportKind: ExportKind; + /** If true, can't use an es6 import from a js file. */ + exportedSymbolIsTypeOnly: boolean; + /** True if export was only found via the package.json AutoImportProvider (for telemetry). */ + isFromPackageJson: boolean; + } + + export interface ExportMapCache { + clear(): void; + get(file: Path, checker: TypeChecker, projectVersion?: string): MultiMap | undefined; + set(suggestions: MultiMap, projectVersion?: string): void; + isEmpty(): boolean; + /** @returns Whether the change resulted in the cache being cleared */ + onFileChanged(oldSourceFile: SourceFile, newSourceFile: SourceFile, typeAcquisitionEnabled: boolean): boolean; + } + export function createExportMapCache(): ExportMapCache { + let cache: MultiMap | undefined; + let projectVersion: string | undefined; + let usableByFileName: Path | undefined; + const wrapped: ExportMapCache = { + isEmpty() { + return !cache; + }, + clear() { + cache = undefined; + projectVersion = undefined; + }, + set(suggestions, version) { + cache = suggestions; + if (version) { + projectVersion = version; + } + }, + get: (file, checker, version) => { + if (usableByFileName && file !== usableByFileName) { + return undefined; + } + if (version && projectVersion === version) { + return cache; + } + cache?.forEach(infos => { + for (const info of infos) { + // If the symbol/moduleSymbol was a merged symbol, it will have a new identity + // in the checker, even though the symbols to merge are the same (guaranteed by + // cache invalidation in synchronizeHostData). + if (info.symbol.declarations?.length) { + info.symbol = checker.getMergedSymbol(info.exportKind === ExportKind.Default + ? info.symbol.declarations[0].localSymbol ?? info.symbol.declarations[0].symbol + : info.symbol.declarations[0].symbol); + } + if (info.moduleSymbol.declarations?.length) { + info.moduleSymbol = checker.getMergedSymbol(info.moduleSymbol.declarations[0].symbol); + } + } + }); + return cache; + }, + onFileChanged(oldSourceFile: SourceFile, newSourceFile: SourceFile, typeAcquisitionEnabled: boolean) { + if (fileIsGlobalOnly(oldSourceFile) && fileIsGlobalOnly(newSourceFile)) { + // File is purely global; doesn't affect export map + return false; + } + if ( + usableByFileName && usableByFileName !== newSourceFile.path || + // If ATA is enabled, auto-imports uses existing imports to guess whether you want auto-imports from node. + // Adding or removing imports from node could change the outcome of that guess, so could change the suggestions list. + typeAcquisitionEnabled && consumesNodeCoreModules(oldSourceFile) !== consumesNodeCoreModules(newSourceFile) || + // Module agumentation and ambient module changes can add or remove exports available to be auto-imported. + // Changes elsewhere in the file can change the *type* of an export in a module augmentation, + // but type info is gathered in getCompletionEntryDetails, which doesn’t use the cache. + !arrayIsEqualTo(oldSourceFile.moduleAugmentations, newSourceFile.moduleAugmentations) || + !ambientModuleDeclarationsAreEqual(oldSourceFile, newSourceFile) + ) { + this.clear(); + return true; + } + usableByFileName = newSourceFile.path; + return false; + }, + }; + if (Debug.isDebugging) { + Object.defineProperty(wrapped, "__cache", { get: () => cache }); + } + return wrapped; + + function fileIsGlobalOnly(file: SourceFile) { + return !file.commonJsModuleIndicator && !file.externalModuleIndicator && !file.moduleAugmentations && !file.ambientModuleNames; + } + + function ambientModuleDeclarationsAreEqual(oldSourceFile: SourceFile, newSourceFile: SourceFile) { + if (!arrayIsEqualTo(oldSourceFile.ambientModuleNames, newSourceFile.ambientModuleNames)) { + return false; + } + let oldFileStatementIndex = -1; + let newFileStatementIndex = -1; + for (const ambientModuleName of newSourceFile.ambientModuleNames) { + const isMatchingModuleDeclaration = (node: Statement) => isNonGlobalAmbientModule(node) && node.name.text === ambientModuleName; + oldFileStatementIndex = findIndex(oldSourceFile.statements, isMatchingModuleDeclaration, oldFileStatementIndex + 1); + newFileStatementIndex = findIndex(newSourceFile.statements, isMatchingModuleDeclaration, newFileStatementIndex + 1); + if (oldSourceFile.statements[oldFileStatementIndex] !== newSourceFile.statements[newFileStatementIndex]) { + return false; + } + } + return true; + } + } + + export function createModuleSpecifierCache(): ModuleSpecifierCache { + let cache: ESMap | undefined; + let importingFileName: Path | undefined; + const wrapped: ModuleSpecifierCache = { + get(fromFileName, toFileName) { + if (!cache || fromFileName !== importingFileName) return undefined; + return cache.get(toFileName); + }, + set(fromFileName, toFileName, moduleSpecifiers) { + if (cache && fromFileName !== importingFileName) { + cache.clear(); + } + importingFileName = fromFileName; + (cache ||= new Map()).set(toFileName, moduleSpecifiers); + }, + clear() { + cache = undefined; + importingFileName = undefined; + }, + count() { + return cache ? cache.size : 0; + } + }; + if (Debug.isDebugging) { + Object.defineProperty(wrapped, "__cache", { get: () => cache }); + } + return wrapped; + } + + export function isImportableFile( + program: Program, + from: SourceFile, + to: SourceFile, + packageJsonFilter: PackageJsonImportFilter | undefined, + moduleSpecifierResolutionHost: ModuleSpecifierResolutionHost, + moduleSpecifierCache: ModuleSpecifierCache | undefined, + ): boolean { + if (from === to) return false; + const cachedResult = moduleSpecifierCache?.get(from.path, to.path); + if (cachedResult !== undefined) { + return !!cachedResult; + } + + const getCanonicalFileName = hostGetCanonicalFileName(moduleSpecifierResolutionHost); + const globalTypingsCache = moduleSpecifierResolutionHost.getGlobalTypingsCacheLocation?.(); + const hasImportablePath = !!moduleSpecifiers.forEachFileNameOfModule( + from.fileName, + to.fileName, + moduleSpecifierResolutionHost, + /*preferSymlinks*/ false, + toPath => { + const toFile = program.getSourceFile(toPath); + // Determine to import using toPath only if toPath is what we were looking at + // or there doesnt exist the file in the program by the symlink + return (toFile === to || !toFile) && + isImportablePath(from.fileName, toPath, getCanonicalFileName, globalTypingsCache); + } + ); + + if (packageJsonFilter) { + const isImportable = hasImportablePath && packageJsonFilter.allowsImportingSourceFile(to, moduleSpecifierResolutionHost); + moduleSpecifierCache?.set(from.path, to.path, isImportable); + return isImportable; + } + + return hasImportablePath; + } + + /** + * Don't include something from a `node_modules` that isn't actually reachable by a global import. + * A relative import to node_modules is usually a bad idea. + */ + function isImportablePath(fromPath: string, toPath: string, getCanonicalFileName: GetCanonicalFileName, globalCachePath?: string): boolean { + // If it's in a `node_modules` but is not reachable from here via a global import, don't bother. + const toNodeModules = forEachAncestorDirectory(toPath, ancestor => getBaseFileName(ancestor) === "node_modules" ? ancestor : undefined); + const toNodeModulesParent = toNodeModules && getDirectoryPath(getCanonicalFileName(toNodeModules)); + return toNodeModulesParent === undefined + || startsWith(getCanonicalFileName(fromPath), toNodeModulesParent) + || (!!globalCachePath && startsWith(getCanonicalFileName(globalCachePath), toNodeModulesParent)); + } + // #endregion } diff --git a/src/testRunner/tsconfig.json b/src/testRunner/tsconfig.json index 3d4f5a21e6397..34c55d0d5da99 100644 --- a/src/testRunner/tsconfig.json +++ b/src/testRunner/tsconfig.json @@ -169,6 +169,7 @@ "unittests/tsserver/events/projectLanguageServiceState.ts", "unittests/tsserver/events/projectLoading.ts", "unittests/tsserver/events/projectUpdatedInBackground.ts", + "unittests/tsserver/exportMapCache.ts", "unittests/tsserver/externalProjects.ts", "unittests/tsserver/forceConsistentCasingInFileNames.ts", "unittests/tsserver/formatSettings.ts", @@ -177,12 +178,12 @@ "unittests/tsserver/getExportReferences.ts", "unittests/tsserver/getFileReferences.ts", "unittests/tsserver/importHelpers.ts", - "unittests/tsserver/importSuggestionsCache.ts", "unittests/tsserver/inferredProjects.ts", "unittests/tsserver/jsdocTag.ts", "unittests/tsserver/languageService.ts", "unittests/tsserver/maxNodeModuleJsDepth.ts", "unittests/tsserver/metadataInResponse.ts", + "unittests/tsserver/moduleSpecifierCache.ts", "unittests/tsserver/navTo.ts", "unittests/tsserver/occurences.ts", "unittests/tsserver/openFile.ts", diff --git a/src/testRunner/unittests/tsserver/completions.ts b/src/testRunner/unittests/tsserver/completions.ts index 0d14838690fa9..8f7e6a51f01d0 100644 --- a/src/testRunner/unittests/tsserver/completions.ts +++ b/src/testRunner/unittests/tsserver/completions.ts @@ -39,7 +39,9 @@ namespace ts.projectSystem { isPackageJsonImport: undefined, sortText: Completions.SortText.AutoImportSuggestions, source: "/a", - data: { exportName: "foo", fileName: "/a.ts", ambientModuleName: undefined, isPackageJsonImport: undefined } + sourceDisplay: undefined, + isSnippet: undefined, + data: { exportName: "foo", fileName: "/a.ts", ambientModuleName: undefined, isPackageJsonImport: undefined, moduleSpecifier: undefined } }; assert.deepEqual(response, { isGlobalCompletion: true, @@ -69,6 +71,7 @@ namespace ts.projectSystem { kindModifiers: ScriptElementKindModifier.exportedModifier, name: "foo", source: [{ text: "./a", kind: "text" }], + sourceDisplay: [{ text: "./a", kind: "text" }], }; assert.deepEqual(detailsResponse, [ { diff --git a/src/testRunner/unittests/tsserver/exportMapCache.ts b/src/testRunner/unittests/tsserver/exportMapCache.ts new file mode 100644 index 0000000000000..803d8b9d62234 --- /dev/null +++ b/src/testRunner/unittests/tsserver/exportMapCache.ts @@ -0,0 +1,88 @@ +namespace ts.projectSystem { + const packageJson: File = { + path: "/package.json", + content: `{ "dependencies": { "mobx": "*" } }` + }; + const aTs: File = { + path: "/a.ts", + content: "export const foo = 0;", + }; + const bTs: File = { + path: "/b.ts", + content: "foo", + }; + const tsconfig: File = { + path: "/tsconfig.json", + content: "{}", + }; + const ambientDeclaration: File = { + path: "/ambient.d.ts", + content: "declare module 'ambient' {}" + }; + const mobxDts: File = { + path: "/node_modules/mobx/index.d.ts", + content: "export declare function observable(): unknown;" + }; + + describe("unittests:: tsserver:: exportMapCache", () => { + it("caches auto-imports in the same file", () => { + const { exportMapCache, checker } = setup(); + assert.ok(exportMapCache.get(bTs.path as Path, checker)); + }); + + it("invalidates the cache when new files are added", () => { + const { host, exportMapCache, checker } = setup(); + host.writeFile("/src/a2.ts", aTs.content); + host.runQueuedTimeoutCallbacks(); + assert.isUndefined(exportMapCache.get(bTs.path as Path, checker)); + }); + + it("invalidates the cache when files are deleted", () => { + const { host, projectService, exportMapCache, checker } = setup(); + projectService.closeClientFile(aTs.path); + host.deleteFile(aTs.path); + host.runQueuedTimeoutCallbacks(); + assert.isUndefined(exportMapCache.get(bTs.path as Path, checker)); + }); + + it("does not invalidate the cache when package.json is changed inconsequentially", () => { + const { host, exportMapCache, checker, project } = setup(); + host.writeFile("/package.json", `{ "name": "blah", "dependencies": { "mobx": "*" } }`); + host.runQueuedTimeoutCallbacks(); + project.getPackageJsonAutoImportProvider(); + assert.ok(exportMapCache.get(bTs.path as Path, checker)); + }); + + it("invalidates the cache when package.json change results in AutoImportProvider change", () => { + const { host, exportMapCache, checker, project } = setup(); + host.writeFile("/package.json", `{}`); + host.runQueuedTimeoutCallbacks(); + project.getPackageJsonAutoImportProvider(); + assert.isUndefined(exportMapCache.get(bTs.path as Path, checker)); + }); + }); + + function setup() { + const host = createServerHost([aTs, bTs, ambientDeclaration, tsconfig, packageJson, mobxDts]); + const session = createSession(host); + openFilesForSession([aTs, bTs], session); + const projectService = session.getProjectService(); + const project = configuredProjectAt(projectService, 0); + triggerCompletions(); + const checker = project.getLanguageService().getProgram()!.getTypeChecker(); + return { host, project, projectService, exportMapCache: project.getExportMapCache(), checker, triggerCompletions }; + + function triggerCompletions() { + const requestLocation: protocol.FileLocationRequestArgs = { + file: bTs.path, + line: 1, + offset: 3, + }; + executeSessionRequest(session, protocol.CommandTypes.CompletionInfo, { + ...requestLocation, + includeExternalModuleExports: true, + prefix: "foo", + }); + } + } +} diff --git a/src/testRunner/unittests/tsserver/importSuggestionsCache.ts b/src/testRunner/unittests/tsserver/importSuggestionsCache.ts deleted file mode 100644 index 982eece3d36ad..0000000000000 --- a/src/testRunner/unittests/tsserver/importSuggestionsCache.ts +++ /dev/null @@ -1,75 +0,0 @@ -namespace ts.projectSystem { - const packageJson: File = { - path: "/package.json", - content: `{ "dependencies": { "mobx": "*" } }` - }; - const aTs: File = { - path: "/a.ts", - content: "export const foo = 0;", - }; - const bTs: File = { - path: "/b.ts", - content: "foo", - }; - const tsconfig: File = { - path: "/tsconfig.json", - content: "{}", - }; - const ambientDeclaration: File = { - path: "/ambient.d.ts", - content: "declare module 'ambient' {}" - }; - const mobxDts: File = { - path: "/node_modules/mobx/index.d.ts", - content: "export declare function observable(): unknown;" - }; - - describe("unittests:: tsserver:: importSuggestionsCache", () => { - it("caches auto-imports in the same file", () => { - const { importSuggestionsCache, checker } = setup(); - assert.ok(importSuggestionsCache.get(bTs.path, checker)); - }); - - it("invalidates the cache when new files are added", () => { - const { host, importSuggestionsCache, checker } = setup(); - host.writeFile("/src/a2.ts", aTs.content); - host.runQueuedTimeoutCallbacks(); - assert.isUndefined(importSuggestionsCache.get(bTs.path, checker)); - }); - - it("invalidates the cache when files are deleted", () => { - const { host, projectService, importSuggestionsCache, checker } = setup(); - projectService.closeClientFile(aTs.path); - host.deleteFile(aTs.path); - host.runQueuedTimeoutCallbacks(); - assert.isUndefined(importSuggestionsCache.get(bTs.path, checker)); - }); - - it("invalidates the cache when package.json is changed", () => { - const { host, importSuggestionsCache, checker } = setup(); - host.writeFile("/package.json", "{}"); - host.runQueuedTimeoutCallbacks(); - assert.isUndefined(importSuggestionsCache.get(bTs.path, checker)); - }); - }); - - function setup() { - const host = createServerHost([aTs, bTs, ambientDeclaration, tsconfig, packageJson, mobxDts]); - const session = createSession(host); - openFilesForSession([aTs, bTs], session); - const projectService = session.getProjectService(); - const project = configuredProjectAt(projectService, 0); - const requestLocation: protocol.FileLocationRequestArgs = { - file: bTs.path, - line: 1, - offset: 3, - }; - executeSessionRequest(session, protocol.CommandTypes.CompletionInfo, { - ...requestLocation, - includeExternalModuleExports: true, - prefix: "foo", - }); - const checker = project.getLanguageService().getProgram()!.getTypeChecker(); - return { host, project, projectService, importSuggestionsCache: project.getImportSuggestionsCache(), checker }; - } -} diff --git a/src/testRunner/unittests/tsserver/jsdocTag.ts b/src/testRunner/unittests/tsserver/jsdocTag.ts index 5033a4fca7fe9..55c386650dbb9 100644 --- a/src/testRunner/unittests/tsserver/jsdocTag.ts +++ b/src/testRunner/unittests/tsserver/jsdocTag.ts @@ -592,6 +592,7 @@ foo` kindModifiers: "", name: "foo", source: undefined, + sourceDisplay: undefined, tags, }]); } diff --git a/src/testRunner/unittests/tsserver/moduleSpecifierCache.ts b/src/testRunner/unittests/tsserver/moduleSpecifierCache.ts new file mode 100644 index 0000000000000..f109cab9d1f6e --- /dev/null +++ b/src/testRunner/unittests/tsserver/moduleSpecifierCache.ts @@ -0,0 +1,81 @@ +namespace ts.projectSystem { + const packageJson: File = { + path: "/package.json", + content: `{ "dependencies": { "mobx": "*" } }` + }; + const aTs: File = { + path: "/a.ts", + content: "export const foo = 0;", + }; + const bTs: File = { + path: "/b.ts", + content: "foo", + }; + const bSymlink: SymLink = { + path: "/b-link.ts", + symLink: "./b.ts", + }; + const tsconfig: File = { + path: "/tsconfig.json", + content: "{}", + }; + const ambientDeclaration: File = { + path: "/ambient.d.ts", + content: "declare module 'ambient' {}" + }; + const mobxDts: File = { + path: "/node_modules/mobx/index.d.ts", + content: "export declare function observable(): unknown;" + }; + + describe("unittests:: tsserver:: moduleSpecifierCache", () => { + it("caches importability within a file", () => { + const { moduleSpecifierCache } = setup(); + assert.isTrue(moduleSpecifierCache.get(bTs.path as Path, aTs.path as Path)); + }); + + it("does not invalidate the cache when new files are added", () => { + const { host, moduleSpecifierCache } = setup(); + host.writeFile("/src/a2.ts", aTs.content); + host.runQueuedTimeoutCallbacks(); + assert.isTrue(moduleSpecifierCache.get(bTs.path as Path, aTs.path as Path)); + }); + + it("invalidates the cache when symlinks are added or removed", () => { + const { host, moduleSpecifierCache } = setup(); + host.renameFile(bSymlink.path, "/b-link2.ts"); + host.runQueuedTimeoutCallbacks(); + assert.equal(moduleSpecifierCache.count(), 0); + }); + + it("invalidates the cache when package.json changes", () => { + const { host, moduleSpecifierCache } = setup(); + host.writeFile("/package.json", `{}`); + host.runQueuedTimeoutCallbacks(); + assert.isUndefined(moduleSpecifierCache.get(bTs.path as Path, aTs.path as Path)); + }); + }); + + function setup() { + const host = createServerHost([aTs, bTs, bSymlink, ambientDeclaration, tsconfig, packageJson, mobxDts]); + const session = createSession(host); + openFilesForSession([aTs, bTs], session); + const projectService = session.getProjectService(); + const project = configuredProjectAt(projectService, 0); + triggerCompletions(); + return { host, project, projectService, moduleSpecifierCache: project.getModuleSpecifierCache(), triggerCompletions }; + + function triggerCompletions() { + const requestLocation: protocol.FileLocationRequestArgs = { + file: bTs.path, + line: 1, + offset: 3, + }; + executeSessionRequest(session, protocol.CommandTypes.CompletionInfo, { + ...requestLocation, + includeExternalModuleExports: true, + prefix: "foo", + }); + } + } +} diff --git a/src/testRunner/unittests/tsserver/partialSemanticServer.ts b/src/testRunner/unittests/tsserver/partialSemanticServer.ts index d7af24e3167bd..edede23aee3b0 100644 --- a/src/testRunner/unittests/tsserver/partialSemanticServer.ts +++ b/src/testRunner/unittests/tsserver/partialSemanticServer.ts @@ -72,6 +72,8 @@ import { something } from "something"; replacementSpan: undefined, source: undefined, data: undefined, + sourceDisplay: undefined, + isSnippet: undefined, }; } }); diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index f3508d60d9a23..6cde5031bcc00 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -3905,6 +3905,8 @@ declare namespace ts { readonly disableSuggestions?: boolean; readonly quotePreference?: "auto" | "double" | "single"; readonly includeCompletionsForModuleExports?: boolean; + readonly includeCompletionsForImportStatements?: boolean; + readonly includeCompletionsWithSnippetText?: boolean; readonly includeAutomaticOptionalChainCompletions?: boolean; readonly includeCompletionsWithInsertText?: boolean; readonly importModuleSpecifierPreference?: "shortest" | "project-relative" | "relative" | "non-relative"; @@ -5654,7 +5656,7 @@ declare namespace ts { fileName: string; } type OrganizeImportsScope = CombinedCodeFixScope; - type CompletionsTriggerCharacter = "." | '"' | "'" | "`" | "/" | "@" | "<" | "#"; + type CompletionsTriggerCharacter = "." | '"' | "'" | "`" | "/" | "@" | "<" | "#" | " "; interface GetCompletionsAtPositionOptions extends UserPreferences { /** * If the editor is asking for completions because a certain character was typed @@ -6140,6 +6142,10 @@ declare namespace ts { * true when the current location also allows for a new identifier */ isNewIdentifierLocation: boolean; + /** + * Indicates to client to continue requesting completions on subsequent keystrokes. + */ + isIncomplete?: true; entries: CompletionEntry[]; } interface CompletionEntryData { @@ -6154,6 +6160,10 @@ declare namespace ts { * in the case of InternalSymbolName.ExportEquals and InternalSymbolName.Default. */ exportName: string; + /** + * Set for auto imports with eagerly resolved module specifiers. + */ + moduleSpecifier?: string; } interface CompletionEntry { name: string; @@ -6161,6 +6171,7 @@ declare namespace ts { kindModifiers?: string; sortText: string; insertText?: string; + isSnippet?: true; /** * An optional span that indicates the text to be replaced by this completion item. * If present, this span should be used instead of the default one. @@ -6169,6 +6180,7 @@ declare namespace ts { replacementSpan?: TextSpan; hasAction?: true; source?: string; + sourceDisplay?: SymbolDisplayPart[]; isRecommended?: true; isFromUncheckedFile?: true; isPackageJsonImport?: true; @@ -6190,7 +6202,9 @@ declare namespace ts { documentation?: SymbolDisplayPart[]; tags?: JSDocTagInfo[]; codeActions?: CodeAction[]; + /** @deprecated Use `sourceDisplay` instead. */ source?: SymbolDisplayPart[]; + sourceDisplay?: SymbolDisplayPart[]; } interface OutliningSpan { /** The span of the document to actually collapse. */ @@ -8214,7 +8228,7 @@ declare namespace ts.server.protocol { command: CommandTypes.Formatonkey; arguments: FormatOnKeyRequestArgs; } - type CompletionsTriggerCharacter = "." | '"' | "'" | "`" | "/" | "@" | "<" | "#"; + type CompletionsTriggerCharacter = "." | '"' | "'" | "`" | "/" | "@" | "<" | "#" | " "; /** * Arguments for completions messages. */ @@ -8316,6 +8330,10 @@ declare namespace ts.server.protocol { * coupled with `replacementSpan` to replace a dotted access with a bracket access. */ insertText?: string; + /** + * `insertText` should be interpreted as a snippet if true. + */ + isSnippet?: true; /** * An optional span that indicates the text to be replaced by this completion item. * If present, this span should be used instead of the default one. @@ -8331,6 +8349,10 @@ declare namespace ts.server.protocol { * Identifier (not necessarily human-readable) identifying where this completion came from. */ source?: string; + /** + * Human-readable description of the `source`. + */ + sourceDisplay?: SymbolDisplayPart[]; /** * If true, this completion should be highlighted as recommended. There will only be one of these. * This will be set when we know the user should write an expression with a certain type and that type is an enum or constructable class. @@ -8387,9 +8409,13 @@ declare namespace ts.server.protocol { */ codeActions?: CodeAction[]; /** - * Human-readable description of the `source` from the CompletionEntry. + * @deprecated Use `sourceDisplay` instead. */ source?: SymbolDisplayPart[]; + /** + * Human-readable description of the `source` from the CompletionEntry. + */ + sourceDisplay?: SymbolDisplayPart[]; } /** @deprecated Prefer CompletionInfoResponse, which supports several top-level fields in addition to the array of entries. */ interface CompletionsResponse extends Response { @@ -8408,6 +8434,7 @@ declare namespace ts.server.protocol { * must be used to commit that completion entry. */ readonly optionalReplacementSpan?: TextSpan; + readonly isIncomplete?: boolean; readonly entries: readonly CompletionEntry[]; } interface CompletionDetailsResponse extends Response { @@ -9190,6 +9217,15 @@ declare namespace ts.server.protocol { * This affects lone identifier completions but not completions on the right hand side of `obj.`. */ readonly includeCompletionsForModuleExports?: boolean; + /** + * Enables auto-import-style completions on partially-typed import statements. E.g., allows + * `import write|` to be completed to `import { writeFile } from "fs"`. + */ + readonly includeCompletionsForImportStatements?: boolean; + /** + * Allows completions to be formatted with snippet text, indicated by `CompletionItem["isSnippet"]`. + */ + readonly includeCompletionsWithSnippetText?: boolean; /** * If enabled, the completion list will include completions with invalid identifier names. * For those entries, The `insertText` and `replacementSpan` properties will be set to change from `.x` property access to `["x"]`. @@ -9617,7 +9653,6 @@ declare namespace ts.server { markAsDirty(): void; getScriptFileNames(): string[]; getLanguageService(): never; - markAutoImportProviderAsDirty(): never; getModuleResolutionHostForAutoImportProvider(): never; getProjectReferences(): readonly ProjectReference[] | undefined; getTypeAcquisition(): TypeAcquisition; diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index 8e5c111382257..7e24af01dfcf3 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -3905,6 +3905,8 @@ declare namespace ts { readonly disableSuggestions?: boolean; readonly quotePreference?: "auto" | "double" | "single"; readonly includeCompletionsForModuleExports?: boolean; + readonly includeCompletionsForImportStatements?: boolean; + readonly includeCompletionsWithSnippetText?: boolean; readonly includeAutomaticOptionalChainCompletions?: boolean; readonly includeCompletionsWithInsertText?: boolean; readonly importModuleSpecifierPreference?: "shortest" | "project-relative" | "relative" | "non-relative"; @@ -5654,7 +5656,7 @@ declare namespace ts { fileName: string; } type OrganizeImportsScope = CombinedCodeFixScope; - type CompletionsTriggerCharacter = "." | '"' | "'" | "`" | "/" | "@" | "<" | "#"; + type CompletionsTriggerCharacter = "." | '"' | "'" | "`" | "/" | "@" | "<" | "#" | " "; interface GetCompletionsAtPositionOptions extends UserPreferences { /** * If the editor is asking for completions because a certain character was typed @@ -6140,6 +6142,10 @@ declare namespace ts { * true when the current location also allows for a new identifier */ isNewIdentifierLocation: boolean; + /** + * Indicates to client to continue requesting completions on subsequent keystrokes. + */ + isIncomplete?: true; entries: CompletionEntry[]; } interface CompletionEntryData { @@ -6154,6 +6160,10 @@ declare namespace ts { * in the case of InternalSymbolName.ExportEquals and InternalSymbolName.Default. */ exportName: string; + /** + * Set for auto imports with eagerly resolved module specifiers. + */ + moduleSpecifier?: string; } interface CompletionEntry { name: string; @@ -6161,6 +6171,7 @@ declare namespace ts { kindModifiers?: string; sortText: string; insertText?: string; + isSnippet?: true; /** * An optional span that indicates the text to be replaced by this completion item. * If present, this span should be used instead of the default one. @@ -6169,6 +6180,7 @@ declare namespace ts { replacementSpan?: TextSpan; hasAction?: true; source?: string; + sourceDisplay?: SymbolDisplayPart[]; isRecommended?: true; isFromUncheckedFile?: true; isPackageJsonImport?: true; @@ -6190,7 +6202,9 @@ declare namespace ts { documentation?: SymbolDisplayPart[]; tags?: JSDocTagInfo[]; codeActions?: CodeAction[]; + /** @deprecated Use `sourceDisplay` instead. */ source?: SymbolDisplayPart[]; + sourceDisplay?: SymbolDisplayPart[]; } interface OutliningSpan { /** The span of the document to actually collapse. */ diff --git a/tests/cases/fourslash/completionEntryForImportName.ts b/tests/cases/fourslash/completionEntryForImportName.ts deleted file mode 100644 index 2567d7f3e117c..0000000000000 --- a/tests/cases/fourslash/completionEntryForImportName.ts +++ /dev/null @@ -1,22 +0,0 @@ -/// - -////import /*1*/ /*2*/ - -verify.completions({ marker: "1", exact: undefined }); -edit.insert('q'); -verify.completions({ exact: undefined }); -verifyIncompleteImportName(); - -goTo.marker('2'); -edit.insert(" = "); -verifyIncompleteImportName(); - -goTo.marker("2"); -edit.moveRight(" = ".length); -edit.insert("a."); -verifyIncompleteImportName(); - -function verifyIncompleteImportName() { - verify.completions({ marker: "1", exact: undefined }); - verify.quickInfoIs("import q"); -} \ No newline at end of file diff --git a/tests/cases/fourslash/completionsImport_defaultAndNamedConflict.ts b/tests/cases/fourslash/completionsImport_defaultAndNamedConflict.ts index 5d8b8ba3cf363..898658921361b 100644 --- a/tests/cases/fourslash/completionsImport_defaultAndNamedConflict.ts +++ b/tests/cases/fourslash/completionsImport_defaultAndNamedConflict.ts @@ -18,8 +18,8 @@ verify.completions({ name: "someModule", source: "/someModule", sourceDisplay: "./someModule", - text: "const someModule: 0", - kind: "const", + text: "(property) default: 1", + kind: "property", kindModifiers: "export", hasAction: true, sortText: completion.SortText.AutoImportSuggestions @@ -28,8 +28,8 @@ verify.completions({ name: "someModule", source: "/someModule", sourceDisplay: "./someModule", - text: "(property) default: 1", - kind: "property", + text: "const someModule: 0", + kind: "const", kindModifiers: "export", hasAction: true, sortText: completion.SortText.AutoImportSuggestions diff --git a/tests/cases/fourslash/completionsImport_details_withMisspelledName.ts b/tests/cases/fourslash/completionsImport_details_withMisspelledName.ts index 098f2e5d46e3d..5f5095301a524 100644 --- a/tests/cases/fourslash/completionsImport_details_withMisspelledName.ts +++ b/tests/cases/fourslash/completionsImport_details_withMisspelledName.ts @@ -4,12 +4,29 @@ ////export const abc = 0; // @Filename: /b.ts -////acb/**/; +////acb/*1*/; -goTo.marker(""); -verify.applyCodeActionFromCompletion("", { +// @Filename: /c.ts +////acb/*2*/; + +goTo.marker("1"); +verify.applyCodeActionFromCompletion("1", { + name: "abc", + source: "/a", + description: `Import 'abc' from module "./a"`, + newFileContent: `import { abc } from "./a"; + +acb;`, +}); + +goTo.marker("2"); +verify.applyCodeActionFromCompletion("2", { name: "abc", source: "/a", + data: { + exportName: "abc", + fileName: "/a.ts", + }, description: `Import 'abc' from module "./a"`, newFileContent: `import { abc } from "./a"; diff --git a/tests/cases/fourslash/completionsImport_exportEquals_global.ts b/tests/cases/fourslash/completionsImport_exportEquals_global.ts index 7e11b169579fa..27d26c387b735 100644 --- a/tests/cases/fourslash/completionsImport_exportEquals_global.ts +++ b/tests/cases/fourslash/completionsImport_exportEquals_global.ts @@ -3,26 +3,28 @@ // @module: es6 // @Filename: /console.d.ts -////interface Console {} -////declare var console: Console; -////declare module "console" { -//// export = console; -////} +//// interface Console {} +//// declare var console: Console; +//// declare module "console" { +//// export = console; +//// } // @Filename: /react-native.d.ts //// import 'console'; -////declare global { -//// interface Console {} -//// var console: Console; -////} +//// declare global { +//// interface Console {} +//// var console: Console; +//// } // @Filename: /a.ts ////conso/**/ verify.completions({ + marker: "", exact: completion.globalsPlus([{ hasAction: undefined, // asserts that it does *not* have an action - name: "console" + name: "console", + sortText: completion.SortText.GlobalsOrKeywords, }]), preferences: { includeCompletionsForModuleExports: true, diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index f9b111dc31f11..d1bc7f429c28d 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -621,6 +621,8 @@ declare namespace FourSlashInterface { interface UserPreferences { readonly quotePreference?: "auto" | "double" | "single"; readonly includeCompletionsForModuleExports?: boolean; + readonly includeCompletionsForImportStatements?: boolean; + readonly includeCompletionsWithSnippetText?: boolean; readonly includeInsertTextCompletions?: boolean; readonly includeAutomaticOptionalChainCompletions?: boolean; readonly importModuleSpecifierPreference?: "shortest" | "project-relative" | "relative" | "non-relative"; @@ -650,6 +652,7 @@ declare namespace FourSlashInterface { readonly kindModifiers?: string; readonly sortText?: completion.SortText; readonly isPackageJsonImport?: boolean; + readonly isSnippet?: boolean; // details readonly text?: string; diff --git a/tests/cases/fourslash/importStatementCompletions1.ts b/tests/cases/fourslash/importStatementCompletions1.ts new file mode 100644 index 0000000000000..c9f991339314d --- /dev/null +++ b/tests/cases/fourslash/importStatementCompletions1.ts @@ -0,0 +1,75 @@ +/// + +// @Filename: /mod.ts +//// export const foo = 0; + +// @Filename: /index0.ts +//// [|import f/*0*/|] + +// @Filename: /index1.ts +//// [|import { f/*1*/}|] + +// @Filename: /index2.ts +//// [|import * as f/*2*/|] + +// @Filename: /index3.ts +//// [|import f/*3*/ from|] + +// @Filename: /index4.ts +//// [|import f/*4*/ =|] + +// @Filename: /index5.ts +//// import f/*5*/ from ""; + +[0, 1, 2, 3, 4, 5].forEach(marker => { + verify.completions({ + marker: "" + marker, + exact: [{ + name: "foo", + source: "./mod", + insertText: `import { foo$1 } from "./mod";`, + isSnippet: true, + replacementSpan: test.ranges()[marker], + sourceDisplay: "./mod", + }], + preferences: { + includeCompletionsForImportStatements: true, + includeInsertTextCompletions: true, + includeCompletionsWithSnippetText: true, + } + }); +}); + +// @Filename: /index6.ts +//// import f/*6*/ from "nope"; + +// @Filename: /index7.ts +//// import { f/*7*/, bar } + +// @Filename: /index8.ts +//// import foo, { f/*8*/ } + +// @Filename: /index9.ts +//// import g/*9*/ + +// @Filename: /index10.ts +//// import f/*10*/ from "./mod"; + +// @Filename: /index11.ts +//// import oo/*11*/ + +// @Filename: /index12.ts +//// import { +//// /*12*/ +//// } + +[6, 7, 8, 9, 10, 11, 12].forEach(marker => { + verify.completions({ + marker: "" + marker, + exact: [], + preferences: { + includeCompletionsForImportStatements: true, + includeInsertTextCompletions: true, + } + }); +}); diff --git a/tests/cases/fourslash/importStatementCompletions_esModuleInterop1.ts b/tests/cases/fourslash/importStatementCompletions_esModuleInterop1.ts new file mode 100644 index 0000000000000..a6d2083489b89 --- /dev/null +++ b/tests/cases/fourslash/importStatementCompletions_esModuleInterop1.ts @@ -0,0 +1,27 @@ +/// + +// @esModuleInterop: false + +// @Filename: /mod.ts +//// const foo = 0; +//// export = foo; + +// @Filename: /importExportEquals.ts +//// [|import f/**/|] + +verify.completions({ + marker: "", + exact: [{ + name: "foo", + source: "./mod", + insertText: `import foo$1 = require("./mod");`, + isSnippet: true, + replacementSpan: test.ranges()[0], + sourceDisplay: "./mod", + }], + preferences: { + includeCompletionsForImportStatements: true, + includeInsertTextCompletions: true, + includeCompletionsWithSnippetText: true, + } +}); diff --git a/tests/cases/fourslash/importStatementCompletions_esModuleInterop2.ts b/tests/cases/fourslash/importStatementCompletions_esModuleInterop2.ts new file mode 100644 index 0000000000000..c6a5b8e25e1f6 --- /dev/null +++ b/tests/cases/fourslash/importStatementCompletions_esModuleInterop2.ts @@ -0,0 +1,27 @@ +/// + +// @esModuleInterop: true + +// @Filename: /mod.ts +//// const foo = 0; +//// export = foo; + +// @Filename: /importExportEquals.ts +//// [|import f/**/|] + +verify.completions({ + marker: "", + exact: [{ + name: "foo", + source: "./mod", + insertText: `import foo$1 from "./mod";`, // <-- default import + isSnippet: true, + replacementSpan: test.ranges()[0], + sourceDisplay: "./mod", + }], + preferences: { + includeCompletionsForImportStatements: true, + includeInsertTextCompletions: true, + includeCompletionsWithSnippetText: true, + } +}); diff --git a/tests/cases/fourslash/importStatementCompletions_noPatternAmbient.ts b/tests/cases/fourslash/importStatementCompletions_noPatternAmbient.ts new file mode 100644 index 0000000000000..78bad2241b0c8 --- /dev/null +++ b/tests/cases/fourslash/importStatementCompletions_noPatternAmbient.ts @@ -0,0 +1,20 @@ +/// + +// @Filename: /types.d.ts +//// declare module "*.css" { +//// const styles: any; +//// export = styles; +//// } + +// @Filename: /index.ts +//// import style/**/ + +verify.completions({ + marker: "", + exact: [], + preferences: { + includeCompletionsForImportStatements: true, + includeInsertTextCompletions: true, + includeCompletionsWithSnippetText: true, + } +}); diff --git a/tests/cases/fourslash/importStatementCompletions_noSnippet.ts b/tests/cases/fourslash/importStatementCompletions_noSnippet.ts new file mode 100644 index 0000000000000..8f87dba53bb39 --- /dev/null +++ b/tests/cases/fourslash/importStatementCompletions_noSnippet.ts @@ -0,0 +1,24 @@ +/// + +// @Filename: /mod.ts +//// export const foo = 0; + +// @Filename: /index0.ts +//// [|import f/**/|] + +verify.completions({ + marker: "", + exact: [{ + name: "foo", + source: "./mod", + insertText: `import { foo } from "./mod";`, // <-- no `$1` tab stop + isSnippet: undefined, // <-- undefined + replacementSpan: test.ranges()[0], + sourceDisplay: "./mod", + }], + preferences: { + includeCompletionsForImportStatements: true, + includeInsertTextCompletions: true, + includeCompletionsWithSnippetText: false, // <-- false + } +}); diff --git a/tests/cases/fourslash/importStatementCompletions_quotes.ts b/tests/cases/fourslash/importStatementCompletions_quotes.ts new file mode 100644 index 0000000000000..845dd0a205caf --- /dev/null +++ b/tests/cases/fourslash/importStatementCompletions_quotes.ts @@ -0,0 +1,25 @@ +/// + +// @Filename: /mod.ts +//// export const foo = 0; + +// @Filename: /single.ts +//// import * as fs from 'fs'; +//// [|import f/**/|] + +verify.completions({ + marker: "", + exact: [{ + name: "foo", + source: "./mod", + insertText: `import { foo$1 } from './mod';`, // <-- single quotes + isSnippet: true, + replacementSpan: test.ranges()[0], + sourceDisplay: "./mod", + }], + preferences: { + includeCompletionsForImportStatements: true, + includeInsertTextCompletions: true, + includeCompletionsWithSnippetText: true, + } +}); diff --git a/tests/cases/fourslash/importStatementCompletions_semicolons.ts b/tests/cases/fourslash/importStatementCompletions_semicolons.ts new file mode 100644 index 0000000000000..fb61be82d790a --- /dev/null +++ b/tests/cases/fourslash/importStatementCompletions_semicolons.ts @@ -0,0 +1,25 @@ +/// + +// @Filename: /mod.ts +//// export const foo = 0; + +// @Filename: /noSemicolons.ts +//// import * as fs from "fs" +//// [|import f/**/|] + +verify.completions({ + marker: "", + exact: [{ + name: "foo", + source: "./mod", + insertText: `import { foo$1 } from "./mod"`, // <-- no semicolon + isSnippet: true, + replacementSpan: test.ranges()[0], + sourceDisplay: "./mod", + }], + preferences: { + includeCompletionsForImportStatements: true, + includeInsertTextCompletions: true, + includeCompletionsWithSnippetText: true, + } +}); diff --git a/tests/cases/fourslash/server/completionsImport_defaultAndNamedConflict_server.ts b/tests/cases/fourslash/server/completionsImport_defaultAndNamedConflict_server.ts index 2532b09d2c8fb..29f0f76cb77fd 100644 --- a/tests/cases/fourslash/server/completionsImport_defaultAndNamedConflict_server.ts +++ b/tests/cases/fourslash/server/completionsImport_defaultAndNamedConflict_server.ts @@ -17,8 +17,8 @@ verify.completions({ name: "someModule", source: "/someModule", sourceDisplay: "./someModule", - text: "const someModule: 0", - kind: "const", + text: "(property) default: 1", + kind: "property", kindModifiers: "export", hasAction: true, sortText: completion.SortText.AutoImportSuggestions, @@ -28,8 +28,8 @@ verify.completions({ name: "someModule", source: "/someModule", sourceDisplay: "./someModule", - text: "(property) default: 1", - kind: "property", + text: "const someModule: 0", + kind: "const", kindModifiers: "export", hasAction: true, sortText: completion.SortText.AutoImportSuggestions,