diff --git a/src/compiler/commandLineParser.ts b/src/compiler/commandLineParser.ts index a43e6244fcbf0..e76ffc766b73d 100644 --- a/src/compiler/commandLineParser.ts +++ b/src/compiler/commandLineParser.ts @@ -2361,7 +2361,7 @@ namespace ts { } const result = matchFileNames(filesSpecs, includeSpecs, excludeSpecs, configFileName ? directoryOfCombinedPath(configFileName, basePath) : basePath, options, host, errors, extraFileExtensions, sourceFile); - if (shouldReportNoInputFiles(result, canJsonReportNoInutFiles(raw), resolutionStack)) { + if (shouldReportNoInputFiles(result, canJsonReportNoInputFiles(raw), resolutionStack)) { errors.push(getErrorForNoInputFiles(result.spec, configFileName)); } @@ -2413,7 +2413,7 @@ namespace ts { } /*@internal*/ - export function canJsonReportNoInutFiles(raw: any) { + export function canJsonReportNoInputFiles(raw: any) { return !hasProperty(raw, "files") && !hasProperty(raw, "references"); } diff --git a/src/compiler/tsbuildPublic.ts b/src/compiler/tsbuildPublic.ts index ac926215b2edc..4b15f22e6ef7b 100644 --- a/src/compiler/tsbuildPublic.ts +++ b/src/compiler/tsbuildPublic.ts @@ -1187,7 +1187,7 @@ namespace ts { else if (reloadLevel === ConfigFileProgramReloadLevel.Partial) { // Update file names const result = getFileNamesFromConfigSpecs(config.configFileSpecs!, getDirectoryPath(project), config.options, state.parseConfigFileHost); - updateErrorForNoInputFiles(result, project, config.configFileSpecs!, config.errors, canJsonReportNoInutFiles(config.raw)); + updateErrorForNoInputFiles(result, project, config.configFileSpecs!, config.errors, canJsonReportNoInputFiles(config.raw)); config.fileNames = result.fileNames; watchInputFiles(state, project, projectPath, config); } @@ -1371,7 +1371,7 @@ namespace ts { } // Container if no files are specified in the project - if (!project.fileNames.length && !canJsonReportNoInutFiles(project.raw)) { + if (!project.fileNames.length && !canJsonReportNoInputFiles(project.raw)) { return { type: UpToDateStatusType.ContainerOnly }; diff --git a/src/compiler/types.ts b/src/compiler/types.ts index 97fe4f408fa50..998bc0d709632 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -8024,6 +8024,7 @@ namespace ts { readonly importModuleSpecifierEnding?: "auto" | "minimal" | "index" | "js"; readonly allowTextChangesInNewFiles?: boolean; readonly providePrefixAndSuffixTextForRename?: boolean; + readonly includePackageJsonAutoImports?: "exclude-dev" | "all" | "none"; readonly provideRefactorNotApplicableReason?: boolean; } diff --git a/src/compiler/watchPublic.ts b/src/compiler/watchPublic.ts index 3bdf80db631f4..cfa951c0cdaec 100644 --- a/src/compiler/watchPublic.ts +++ b/src/compiler/watchPublic.ts @@ -671,7 +671,7 @@ namespace ts { configFileSpecs = configFileParseResult.configFileSpecs!; // TODO: GH#18217 projectReferences = configFileParseResult.projectReferences; configFileParsingDiagnostics = getConfigFileParsingDiagnostics(configFileParseResult).slice(); - canConfigFileJsonReportNoInputFiles = canJsonReportNoInutFiles(configFileParseResult.raw); + canConfigFileJsonReportNoInputFiles = canJsonReportNoInputFiles(configFileParseResult.raw); hasChangedConfigFileParsingErrors = true; } diff --git a/src/harness/client.ts b/src/harness/client.ts index 381177bdf23e4..76cafd9ee47b8 100644 --- a/src/harness/client.ts +++ b/src/harness/client.ts @@ -131,6 +131,13 @@ namespace ts.server { this.processResponse(request, /*expectEmptyBody*/ true); } + /*@internal*/ + setFormattingOptions(formatOptions: FormatCodeSettings) { + const args: protocol.ConfigureRequestArguments = { formatOptions }; + const request = this.processRequest(CommandNames.Configure, args); + this.processResponse(request, /*expectEmptyBody*/ true); + } + openFile(file: string, fileContent?: string, scriptKindName?: "TS" | "JS" | "TSX" | "JSX"): void { const args: protocol.OpenRequestArgs = { file, fileContent, scriptKindName }; this.processRequest(CommandNames.Open, args); @@ -791,7 +798,11 @@ namespace ts.server { } getProgram(): Program { - throw new Error("SourceFile objects are not serializable through the server protocol."); + throw new Error("Program objects are not serializable through the server protocol."); + } + + getAutoImportProvider(): Program | undefined { + throw new Error("Program objects are not serializable through the server protocol."); } getNonBoundSourceFile(_fileName: string): SourceFile { diff --git a/src/harness/fourslashImpl.ts b/src/harness/fourslashImpl.ts index 9af23f66d2381..1056d0329ee45 100644 --- a/src/harness/fourslashImpl.ts +++ b/src/harness/fourslashImpl.ts @@ -871,14 +871,12 @@ namespace FourSlash { } private verifyCompletionEntry(actual: ts.CompletionEntry, expected: FourSlashInterface.ExpectedCompletionEntry) { - const { insertText, replacementSpan, hasAction, isRecommended, isFromUncheckedFile, kind, kindModifiers, text, documentation, tags, source, sourceDisplay, sortText } = typeof expected === "string" - ? { insertText: undefined, replacementSpan: undefined, hasAction: undefined, isRecommended: undefined, isFromUncheckedFile: undefined, kind: undefined, kindModifiers: undefined, text: undefined, documentation: undefined, tags: undefined, source: undefined, sourceDisplay: undefined, sortText: undefined } - : expected; + expected = typeof expected === "string" ? { name: expected } : expected; - if (actual.insertText !== insertText) { - this.raiseError(`Expected completion insert text to be ${insertText}, got ${actual.insertText}`); + if (actual.insertText !== expected.insertText) { + this.raiseError(`Expected completion insert text to be ${expected.insertText}, got ${actual.insertText}`); } - const convertedReplacementSpan = replacementSpan && ts.createTextSpanFromRange(replacementSpan); + const convertedReplacementSpan = expected.replacementSpan && ts.createTextSpanFromRange(expected.replacementSpan); try { assert.deepEqual(actual.replacementSpan, convertedReplacementSpan); } @@ -886,38 +884,34 @@ namespace FourSlash { this.raiseError(`Expected completion replacementSpan to be ${stringify(convertedReplacementSpan)}, got ${stringify(actual.replacementSpan)}`); } - if (kind !== undefined || kindModifiers !== undefined) { - if (actual.kind !== kind) { - this.raiseError(`Unexpected kind for ${actual.name}: Expected '${kind}', actual '${actual.kind}'`); - } - if (actual.kindModifiers !== (kindModifiers || "")) { - this.raiseError(`Bad kindModifiers for ${actual.name}: Expected ${kindModifiers || ""}, actual ${actual.kindModifiers}`); - } + if (expected.kind !== undefined || expected.kindModifiers !== undefined) { + assert.equal(actual.kind, expected.kind, `Expected 'kind' for ${actual.name} to match`); + assert.equal(actual.kindModifiers, expected.kindModifiers || "", `Expected 'kindModifiers' for ${actual.name} to match`); } - - if (isFromUncheckedFile !== undefined) { - if (actual.isFromUncheckedFile !== isFromUncheckedFile) { - this.raiseError(`Expected 'isFromUncheckedFile' value '${actual.isFromUncheckedFile}' to equal '${isFromUncheckedFile}'`); - } + if (expected.isFromUncheckedFile !== undefined) { + assert.equal(actual.isFromUncheckedFile, expected.isFromUncheckedFile, "Expected 'isFromUncheckedFile' properties to match"); + } + if (expected.isPackageJsonImport !== undefined) { + assert.equal(actual.isPackageJsonImport, expected.isPackageJsonImport, "Expected 'isPackageJsonImport' properties to match"); } - assert.equal(actual.hasAction, hasAction, `Expected 'hasAction' properties to match`); - assert.equal(actual.isRecommended, isRecommended, `Expected 'isRecommended' properties to match'`); - assert.equal(actual.source, source, `Expected 'source' values to match`); - assert.equal(actual.sortText, sortText || ts.Completions.SortText.LocationPriority, this.messageAtLastKnownMarker(`Actual entry: ${JSON.stringify(actual)}`)); + 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.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)}`)); - if (text !== undefined) { + if (expected.text !== undefined) { const actualDetails = this.getCompletionEntryDetails(actual.name, actual.source)!; - assert.equal(ts.displayPartsToString(actualDetails.displayParts), text, "Expected 'text' property to match 'displayParts' string"); - assert.equal(ts.displayPartsToString(actualDetails.documentation), documentation || "", "Expected 'documentation' property to match 'documentation' display parts string"); + assert.equal(ts.displayPartsToString(actualDetails.displayParts), expected.text, "Expected 'text' property to match 'displayParts' string"); + assert.equal(ts.displayPartsToString(actualDetails.documentation), expected.documentation || "", "Expected 'documentation' property to match 'documentation' display parts string"); // TODO: GH#23587 // 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), sourceDisplay, "Expected 'sourceDisplay' property to match 'source' display parts string"); - assert.deepEqual(actualDetails.tags, tags); + assert.equal(actualDetails.source && ts.displayPartsToString(actualDetails.source), expected.sourceDisplay, "Expected 'sourceDisplay' property to match 'source' display parts string"); + assert.deepEqual(actualDetails.tags, expected.tags); } else { - assert(documentation === undefined && tags === undefined && sourceDisplay === undefined, "If specifying completion details, should specify 'text'"); + assert(expected.documentation === undefined && expected.tags === undefined && expected.sourceDisplay === undefined, "If specifying completion details, should specify 'text'"); } } @@ -2122,6 +2116,9 @@ namespace FourSlash { public setFormatOptions(formatCodeOptions: ts.FormatCodeOptions | ts.FormatCodeSettings): ts.FormatCodeSettings { const oldFormatCodeOptions = this.formatCodeSettings; this.formatCodeSettings = ts.toEditorSettings(formatCodeOptions); + if (this.testType === FourSlashTestType.Server) { + (this.languageService as ts.server.SessionClient).setFormattingOptions(this.formatCodeSettings); + } return oldFormatCodeOptions; } diff --git a/src/harness/fourslashInterfaceImpl.ts b/src/harness/fourslashInterfaceImpl.ts index a2785bef3d712..238e5075f97f1 100644 --- a/src/harness/fourslashInterfaceImpl.ts +++ b/src/harness/fourslashInterfaceImpl.ts @@ -731,7 +731,7 @@ namespace FourSlashInterface { } public setOption(name: keyof ts.FormatCodeSettings, value: number | string | boolean): void { - this.state.formatCodeSettings = { ...this.state.formatCodeSettings, [name]: value }; + this.state.setFormatOptions({ ...this.state.formatCodeSettings, [name]: value }); } } @@ -1495,6 +1495,7 @@ namespace FourSlashInterface { readonly isRecommended?: boolean; // If not specified, will assert that this is false. 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 kindModifiers?: string; // Must be paired with 'kind' readonly text?: string; readonly documentation?: string; diff --git a/src/harness/harnessLanguageService.ts b/src/harness/harnessLanguageService.ts index fbaf9ba545d0a..b13b017559297 100644 --- a/src/harness/harnessLanguageService.ts +++ b/src/harness/harnessLanguageService.ts @@ -588,6 +588,9 @@ namespace Harness.LanguageService { getProgram(): ts.Program { throw new Error("Program can not be marshaled across the shim layer."); } + getAutoImportProvider(): ts.Program | undefined { + throw new Error("Program can not be marshaled across the shim layer."); + } getNonBoundSourceFile(): ts.SourceFile { throw new Error("SourceFile can not be marshaled across the shim layer."); } diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 730d8c0d5fa47..decb0d818b945 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -150,12 +150,6 @@ namespace ts.server { export type ProjectServiceEventHandler = (event: ProjectServiceEvent) => void; - /*@internal*/ - export interface PerformanceEvent { - kind: "UpdateGraph"; - durationMs: number; - } - /*@internal*/ export type PerformanceEventHandler = (event: PerformanceEvent) => void; @@ -674,6 +668,12 @@ namespace ts.server { /*@internal*/ readonly watchFactory: WatchFactory; + /*@internal*/ + readonly packageJsonCache: PackageJsonCache; + /*@internal*/ + private packageJsonFilesMap: Map | undefined; + + private performanceEventHandler?: PerformanceEventHandler; constructor(opts: ProjectServiceOptions) { @@ -725,6 +725,7 @@ namespace ts.server { const watchLogLevel = this.logger.hasLevel(LogLevel.verbose) ? WatchLogLevel.Verbose : this.logger.loggingEnabled() ? WatchLogLevel.TriggerOnly : WatchLogLevel.None; const log: (s: string) => void = watchLogLevel !== WatchLogLevel.None ? (s => this.logger.info(s)) : noop; + this.packageJsonCache = createPackageJsonCache(this); this.watchFactory = this.syntaxOnly ? { watchFile: returnNoopFileWatcher, @@ -921,9 +922,9 @@ namespace ts.server { } /* @internal */ - sendUpdateGraphPerformanceEvent(durationMs: number) { + sendPerformanceEvent(kind: PerformanceEvent["kind"], durationMs: number) { if (this.performanceEventHandler) { - this.performanceEventHandler({ kind: "UpdateGraph", durationMs }); + this.performanceEventHandler({ kind, durationMs }); } } @@ -1162,6 +1163,7 @@ namespace ts.server { */ /*@internal*/ watchWildcardDirectory(directory: Path, flags: WatchDirectoryFlags, project: ConfiguredProject) { + const watchOptions = this.getWatchOptions(project); return this.watchFactory.watchDirectory( this.host, directory, @@ -1173,7 +1175,7 @@ namespace ts.server { (fsResult && fsResult.fileExists || !fsResult && this.host.fileExists(fileOrDirectoryPath)) ) { this.logger.info(`Project: ${configFileName} Detected new package.json: ${fileOrDirectory}`); - project.onAddPackageJson(fileOrDirectoryPath); + this.onAddPackageJson(fileOrDirectoryPath); } if (isIgnoredFileFromWildCardWatching({ @@ -1212,7 +1214,7 @@ namespace ts.server { } }, flags, - this.getWatchOptions(project), + watchOptions, WatchType.WildcardDirectory, project ); @@ -2025,7 +2027,7 @@ namespace ts.server { }; } project.configFileSpecs = parsedCommandLine.configFileSpecs; - project.canConfigFileJsonReportNoInputFiles = canJsonReportNoInutFiles(parsedCommandLine.raw); + project.canConfigFileJsonReportNoInputFiles = canJsonReportNoInputFiles(parsedCommandLine.raw); project.setProjectErrors(configFileErrors); project.updateReferences(parsedCommandLine.projectReferences); const lastFileExceededProgramSize = this.getFilenameForExceededTotalSizeLimitForNonTsFiles(project.canonicalConfigFilePath, compilerOptions, parsedCommandLine.fileNames, fileNamePropertyReader); @@ -2044,7 +2046,7 @@ namespace ts.server { this.updateRootAndOptionsOfNonInferredProject(project, filesToAdd, fileNamePropertyReader, compilerOptions, parsedCommandLine.typeAcquisition!, parsedCommandLine.compileOnSave, parsedCommandLine.watchOptions); } - private updateNonInferredProjectFiles(project: ExternalProject | ConfiguredProject, files: T[], propertyReader: FilePropertyReader) { + private updateNonInferredProjectFiles(project: ExternalProject | ConfiguredProject | AutoImportProviderProject, files: T[], propertyReader: FilePropertyReader) { const projectRootFilesMap = project.getRootFilesMap(); const newRootScriptInfoMap = createMap(); @@ -2141,6 +2143,11 @@ namespace ts.server { return project.updateGraph(); } + /*@internal*/ + setFileNamesOfAutoImportProviderProject(project: AutoImportProviderProject, fileNames: string[]) { + this.updateNonInferredProjectFiles(project, fileNames, fileNamePropertyReader); + } + /** * Read the config file of the project again by clearing the cache and update the project graph */ @@ -2685,7 +2692,7 @@ namespace ts.server { this.logger.info("Format host information updated"); } if (args.preferences) { - const { lazyConfiguredProjectsFromExternalProject } = this.hostConfiguration.preferences; + const { lazyConfiguredProjectsFromExternalProject, includePackageJsonAutoImports } = this.hostConfiguration.preferences; this.hostConfiguration.preferences = { ...this.hostConfiguration.preferences, ...args.preferences }; if (lazyConfiguredProjectsFromExternalProject && !this.hostConfiguration.preferences.lazyConfiguredProjectsFromExternalProject) { // Load configured projects for external projects that are pending reload @@ -2697,6 +2704,9 @@ namespace ts.server { } }); } + if (includePackageJsonAutoImports !== args.preferences.includePackageJsonAutoImports) { + this.invalidateProjectAutoImports(/*packageJsonPath*/ undefined); + } } if (args.extraFileExtensions) { this.hostConfiguration.extraFileExtensions = args.extraFileExtensions; @@ -3201,7 +3211,7 @@ namespace ts.server { const toRemoveScriptInfos = cloneMap(this.filenameToScriptInfo); this.filenameToScriptInfo.forEach(info => { // If script info is open or orphan, retain it and its dependencies - if (!info.isScriptOpen() && info.isOrphan()) { + if (!info.isScriptOpen() && info.isOrphan() && !info.isContainedByAutoImportProvider()) { // Otherwise if there is any source info that is alive, this alive too if (!info.sourceMapFilePath) return; let sourceInfos: Map | undefined; @@ -3679,6 +3689,95 @@ namespace ts.server { this.currentPluginConfigOverrides = this.currentPluginConfigOverrides || createMap(); this.currentPluginConfigOverrides.set(args.pluginName, args.configuration); } + + /*@internal*/ + getPackageJsonsVisibleToFile(fileName: string, rootDir?: string): readonly PackageJsonInfo[] { + const packageJsonCache = this.packageJsonCache; + const watchPackageJsonFile = this.watchPackageJsonFile.bind(this); + const toPath = this.toPath.bind(this); + const rootPath = rootDir && toPath(rootDir); + const filePath = toPath(fileName); + const result: PackageJsonInfo[] = []; + forEachAncestorDirectory(getDirectoryPath(filePath), function processDirectory(directory): boolean | undefined { + switch (packageJsonCache.directoryHasPackageJson(directory)) { + // Sync and check same directory again + case Ternary.Maybe: + packageJsonCache.searchDirectoryAndAncestors(directory); + return processDirectory(directory); + // Check package.json + case Ternary.True: + const packageJsonFileName = combinePaths(directory, "package.json"); + watchPackageJsonFile(packageJsonFileName); + const info = packageJsonCache.getInDirectory(directory); + if (info) result.push(info); + } + if (rootPath && rootPath === toPath(directory)) { + return true; + } + }); + + return result; + } + + /*@internal*/ + private watchPackageJsonFile(path: Path) { + const watchers = this.packageJsonFilesMap || (this.packageJsonFilesMap = createMap()); + if (!watchers.has(path)) { + this.invalidateProjectAutoImports(path); + watchers.set(path, this.watchFactory.watchFile( + this.host, + path, + (fileName, eventKind) => { + const path = this.toPath(fileName); + switch (eventKind) { + case FileWatcherEventKind.Created: + return Debug.fail(); + case FileWatcherEventKind.Changed: + this.packageJsonCache.addOrUpdate(path); + this.invalidateProjectAutoImports(path); + break; + case FileWatcherEventKind.Deleted: + this.packageJsonCache.delete(path); + this.invalidateProjectAutoImports(path); + watchers.get(path)!.close(); + watchers.delete(path); + } + }, + PollingInterval.Low, + this.hostConfiguration.watchOptions, + WatchType.PackageJsonFile, + )); + } + } + + /*@internal*/ + private onAddPackageJson(path: Path) { + this.packageJsonCache.addOrUpdate(path); + this.watchPackageJsonFile(path); + } + + /*@internal*/ + includePackageJsonAutoImports(): PackageJsonAutoImportPreference { + switch (this.hostConfiguration.preferences.includePackageJsonAutoImports) { + case "none": return PackageJsonAutoImportPreference.None; + case "all": return PackageJsonAutoImportPreference.All; + default: return PackageJsonAutoImportPreference.ExcludeDevDependencies; + } + } + + /*@internal*/ + private invalidateProjectAutoImports(packageJsonPath: Path | undefined) { + if (this.includePackageJsonAutoImports()) { + this.configuredProjects.forEach(invalidate); + this.inferredProjects.forEach(invalidate); + this.externalProjects.forEach(invalidate); + } + function invalidate(project: Project) { + if (!packageJsonPath || project.packageJsonsForAutoImport?.has(packageJsonPath)) { + project.markAutoImportProviderAsDirty(); + } + } + } } /* @internal */ diff --git a/src/server/packageJsonCache.ts b/src/server/packageJsonCache.ts index 290711a821721..18a4a8a36bf61 100644 --- a/src/server/packageJsonCache.ts +++ b/src/server/packageJsonCache.ts @@ -2,17 +2,21 @@ namespace ts.server { export interface PackageJsonCache { addOrUpdate(fileName: Path): void; + forEach(action: (info: PackageJsonInfo, fileName: Path) => void): void; delete(fileName: Path): void; + get(fileName: Path): PackageJsonInfo | false | undefined; getInDirectory(directory: Path): PackageJsonInfo | undefined; directoryHasPackageJson(directory: Path): Ternary; searchDirectoryAndAncestors(directory: Path): void; } - export function createPackageJsonCache(project: Project): PackageJsonCache { - const packageJsons = createMap(); + export function createPackageJsonCache(host: ProjectService): PackageJsonCache { + const packageJsons = createMap(); const directoriesWithoutPackageJson = createMap(); return { addOrUpdate, + forEach: packageJsons.forEach.bind(packageJsons), + get: packageJsons.get.bind(packageJsons), delete: fileName => { packageJsons.delete(fileName); directoriesWithoutPackageJson.set(getDirectoryPath(fileName), true); @@ -26,8 +30,8 @@ namespace ts.server { if (directoryHasPackageJson(ancestor) !== Ternary.Maybe) { return true; } - const packageJsonFileName = project.toPath(combinePaths(ancestor, "package.json")); - if (tryFileExists(project, packageJsonFileName)) { + const packageJsonFileName = host.toPath(combinePaths(ancestor, "package.json")); + if (tryFileExists(host, packageJsonFileName)) { addOrUpdate(packageJsonFileName); } else { @@ -38,7 +42,7 @@ namespace ts.server { }; function addOrUpdate(fileName: Path) { - const packageJsonInfo = createPackageJsonInfo(fileName, project); + const packageJsonInfo = createPackageJsonInfo(fileName, host.host); if (packageJsonInfo !== undefined) { packageJsons.set(fileName, packageJsonInfo); directoriesWithoutPackageJson.delete(getDirectoryPath(fileName)); diff --git a/src/server/project.ts b/src/server/project.ts index 79b887cf067d2..688d7b2654e6c 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -3,7 +3,8 @@ namespace ts.server { export enum ProjectKind { Inferred, Configured, - External + External, + AutoImportProvider, } /* @internal */ @@ -133,9 +134,6 @@ namespace ts.server { private generatedFilesMap: GeneratedFileWatcherMap | undefined; private plugins: PluginModuleWithName[] = []; - /*@internal*/ - private packageJsonFilesMap: Map | undefined; - /*@internal*/ /** * This is map from files to unresolved imports in it @@ -202,6 +200,9 @@ namespace ts.server { /*@internal*/ originalConfiguredProjects: Map | undefined; + /*@internal*/ + packageJsonsForAutoImport: Map | undefined; + /*@internal*/ getResolvedProjectReferenceToRedirect(_fileName: string): ResolvedProjectReference | undefined { return undefined; @@ -240,15 +241,14 @@ namespace ts.server { /*@internal*/ public readonly getCanonicalFileName: GetCanonicalFileName; - /*@internal*/ - readonly packageJsonCache: PackageJsonCache; - /*@internal*/ private importSuggestionsCache = Completions.createImportSuggestionsForFileCache(); /*@internal*/ private dirtyFilesForSuggestions: Map | undefined; /*@internal*/ private symlinks: ReadonlyMap | undefined; + /*@internal*/ + autoImportProviderHost: AutoImportProviderProject | false | undefined; /*@internal*/ constructor( @@ -301,8 +301,9 @@ namespace ts.server { this.disableLanguageService(lastFileExceededProgramSize); } this.markAsDirty(); - this.projectService.pendingEnsureProjectForOpenFiles = true; - this.packageJsonCache = createPackageJsonCache(this); + if (projectKind !== ProjectKind.AutoImportProvider) { + this.projectService.pendingEnsureProjectForOpenFiles = true; + } } isKnownTypesPackageName(name: string): boolean { @@ -661,6 +662,10 @@ namespace ts.server { this.languageServiceEnabled = false; this.lastFileExceededProgramSize = lastFileExceededProgramSize; this.builderState = undefined; + if (this.autoImportProviderHost) { + this.autoImportProviderHost.close(); + } + this.autoImportProviderHost = undefined; this.resolutionCache.closeTypeRootsWatch(); this.clearGeneratedFileWatch(); this.projectService.onUpdateLanguageServiceStateForProject(this, /*languageServiceEnabled*/ false); @@ -721,6 +726,7 @@ namespace ts.server { } }); } + // Release external files forEach(this.externalFiles, externalFile => this.detachScriptInfoIfNotRoot(externalFile)); // Always remove root files from the project @@ -744,12 +750,12 @@ namespace ts.server { clearMap(this.missingFilesMap, closeFileWatcher); this.missingFilesMap = undefined!; } - if (this.packageJsonFilesMap) { - clearMap(this.packageJsonFilesMap, closeFileWatcher); - this.packageJsonFilesMap = undefined; - } this.clearGeneratedFileWatch(); this.clearInvalidateResolutionOfFailedLookupTimer(); + if (this.autoImportProviderHost) { + this.autoImportProviderHost.close(); + } + this.autoImportProviderHost = undefined; // signal language service to release source files acquired from document registry this.languageService.dispose(); @@ -946,6 +952,15 @@ namespace ts.server { } } + /*@internal*/ + markAutoImportProviderAsDirty() { + if (this.autoImportProviderHost === false) { + this.autoImportProviderHost = undefined; + } + this.autoImportProviderHost?.markAsDirty(); + this.importSuggestionsCache.clear(); + } + /* @internal */ onFileAddedOrRemoved() { this.hasAddedorRemovedFiles = true; @@ -989,9 +1004,18 @@ namespace ts.server { this.lastCachedUnresolvedImportsList = undefined; } + const isFirstLoad = this.projectProgramVersion === 0; if (hasNewProgram) { this.projectProgramVersion++; } + if (hasAddedorRemovedFiles) { + if (!this.autoImportProviderHost) this.autoImportProviderHost = undefined; + this.autoImportProviderHost?.markAsDirty(); + } + if (isFirstLoad) { + // Preload auto import provider so it's not created during completions request + this.getPackageJsonAutoImportProvider(); + } perfLogger.logStopUpdateGraph(); return !hasNewProgram; } @@ -1135,7 +1159,7 @@ namespace ts.server { removed => this.detachScriptInfoFromProject(removed) ); const elapsed = timestamp() - start; - this.projectService.sendUpdateGraphPerformanceEvent(elapsed); + this.sendPerformanceEvent("UpdateGraph", elapsed); this.writeLog(`Finishing updateGraphWorker: Project: ${this.getProjectName()} Version: ${this.getProjectVersion()} structureChanged: ${hasNewProgram} Elapsed: ${elapsed}ms`); if (this.hasAddedorRemovedFiles) { this.print(/*writeProjectFileNames*/ true); @@ -1146,6 +1170,11 @@ namespace ts.server { return hasNewProgram; } + /* @internal */ + sendPerformanceEvent(kind: PerformanceEvent["kind"], durationMs: number) { + this.projectService.sendPerformanceEvent(kind, durationMs); + } + /*@internal*/ private sourceFileHasChangedOwnImportSuggestions(oldSourceFile: SourceFile | undefined, newSourceFile: SourceFile | undefined) { if (!oldSourceFile && !newSourceFile) { @@ -1322,6 +1351,9 @@ namespace ts.server { this.writeLog(`Project '${this.projectName}' (${ProjectKind[this.projectKind]})`); this.writeLog(this.filesToString(writeProjectFileNames && this.projectService.logger.hasLevel(LogLevel.verbose))); this.writeLog("-----------------------------------------------"); + if (this.autoImportProviderHost) { + this.autoImportProviderHost.print(/*writeProjectFileNames*/ false); + } } setCompilerOptions(compilerOptions: CompilerOptions) { @@ -1572,37 +1604,14 @@ namespace ts.server { /*@internal*/ getPackageJsonsVisibleToFile(fileName: string, rootDir?: string): readonly PackageJsonInfo[] { - const packageJsonCache = this.packageJsonCache; - const watchPackageJsonFile = this.watchPackageJsonFile.bind(this); - const toPath = this.toPath.bind(this); - const rootPath = rootDir && toPath(rootDir); - const filePath = toPath(fileName); - const result: PackageJsonInfo[] = []; - forEachAncestorDirectory(getDirectoryPath(filePath), function processDirectory(directory): boolean | undefined { - switch (packageJsonCache.directoryHasPackageJson(directory)) { - // Sync and check same directory again - case Ternary.Maybe: - packageJsonCache.searchDirectoryAndAncestors(directory); - return processDirectory(directory); - // Check package.json - case Ternary.True: - const packageJsonFileName = combinePaths(directory, "package.json"); - watchPackageJsonFile(packageJsonFileName); - const info = packageJsonCache.getInDirectory(directory); - if (info) result.push(info); - } - if (rootPath && rootPath === toPath(directory)) { - return true; - } - }); - - return result; + return this.projectService.getPackageJsonsVisibleToFile(fileName, rootDir); } /*@internal*/ - onAddPackageJson(path: Path) { - this.packageJsonCache.addOrUpdate(path); - this.watchPackageJsonFile(path); + getPackageJsonsForAutoImport(rootDir?: string): readonly PackageJsonInfo[] { + const packageJsons = this.getPackageJsonsVisibleToFile(combinePaths(this.currentDirectory, inferredTypesContainingFile), rootDir); + this.packageJsonsForAutoImport = arrayToSet(packageJsons.map(p => p.fileName)); + return packageJsons; } /*@internal*/ @@ -1610,31 +1619,47 @@ namespace ts.server { return this.importSuggestionsCache; } - private watchPackageJsonFile(path: Path) { - const watchers = this.packageJsonFilesMap || (this.packageJsonFilesMap = createMap()); - if (!watchers.has(path)) { - watchers.set(path, this.projectService.watchFactory.watchFile( - this.projectService.host, - path, - (fileName, eventKind) => { - const path = this.toPath(fileName); - switch (eventKind) { - case FileWatcherEventKind.Created: - return Debug.fail(); - case FileWatcherEventKind.Changed: - this.packageJsonCache.addOrUpdate(path); - break; - case FileWatcherEventKind.Deleted: - this.packageJsonCache.delete(path); - watchers.get(path)!.close(); - watchers.delete(path); - } - }, - PollingInterval.Low, - this.projectService.getWatchOptions(this), - WatchType.PackageJsonFile, - )); + /*@internal*/ + includePackageJsonAutoImports(): PackageJsonAutoImportPreference { + if (this.projectService.includePackageJsonAutoImports() === PackageJsonAutoImportPreference.None || + !this.languageServiceEnabled || + isInsideNodeModules(this.currentDirectory) || + !this.isDefaultProjectForOpenFiles()) { + return PackageJsonAutoImportPreference.None; + } + return this.projectService.includePackageJsonAutoImports(); + } + + /*@internal*/ + getPackageJsonAutoImportProvider(): Program | undefined { + if (this.autoImportProviderHost === false) { + return undefined; } + if (this.autoImportProviderHost) { + updateProjectIfDirty(this.autoImportProviderHost); + if (!this.autoImportProviderHost.hasRoots()) { + this.autoImportProviderHost.close(); + this.autoImportProviderHost = undefined; + return undefined; + } + return this.autoImportProviderHost.getCurrentProgram(); + } + + const dependencySelection = this.includePackageJsonAutoImports(); + if (dependencySelection) { + this.autoImportProviderHost = AutoImportProviderProject.create(dependencySelection, this, this.projectService.host, this.documentRegistry); + if (this.autoImportProviderHost) { + updateProjectIfDirty(this.autoImportProviderHost); + return this.autoImportProviderHost.getCurrentProgram(); + } + } + } + + /*@internal*/ + private isDefaultProjectForOpenFiles(): boolean { + return !!forEachEntry( + this.projectService.openFiles, + (_, fileName) => this.projectService.tryGetDefaultProjectForFile(toNormalizedPath(fileName)) === this); } } @@ -1659,19 +1684,17 @@ namespace ts.server { }); } + function createProjectNameFactoryWithCounter(nameFactory: (counter: number) => string) { + let nextId = 1; + return () => nameFactory(nextId++); + } + /** * If a file is opened and no tsconfig (or jsconfig) is found, * the file and its imports/references are put into an InferredProject. */ export class InferredProject extends Project { - private static readonly newName = (() => { - let nextId = 1; - return () => { - const id = nextId; - nextId++; - return makeInferredProjectName(id); - }; - })(); + private static readonly newName = createProjectNameFactoryWithCounter(makeInferredProjectName); private _isJsInferredProject = false; @@ -1773,12 +1796,151 @@ namespace ts.server { getTypeAcquisition(): TypeAcquisition { return { enable: allRootFilesAreJsOrDts(this), - include: [], - exclude: [] + include: ts.emptyArray, + exclude: ts.emptyArray }; } } + export class AutoImportProviderProject extends Project { + private static readonly newName = createProjectNameFactoryWithCounter(makeAutoImportProviderProjectName); + + /*@internal*/ + static getRootFileNames(dependencySelection: PackageJsonAutoImportPreference, hostProject: Project, moduleResolutionHost: ModuleResolutionHost, compilerOptions: CompilerOptions): string[] { + if (!dependencySelection) { + return ts.emptyArray; + } + + let dependencyNames: Map | undefined; + let rootNames: string[] | undefined; + const rootFileName = combinePaths(hostProject.currentDirectory, inferredTypesContainingFile); + const packageJsons = hostProject.getPackageJsonsForAutoImport(combinePaths(hostProject.currentDirectory, rootFileName)); + for (const packageJson of packageJsons) { + packageJson.dependencies?.forEach((_, dependenyName) => addDependency(dependenyName)); + packageJson.peerDependencies?.forEach((_, dependencyName) => addDependency(dependencyName)); + if (dependencySelection === PackageJsonAutoImportPreference.All) { + packageJson.devDependencies?.forEach((_, dependencyName) => addDependency(dependencyName)); + } + } + + if (dependencyNames) { + const resolutions = map(arrayFrom(dependencyNames.keys()), name => resolveTypeReferenceDirective( + name, + rootFileName, + compilerOptions, + moduleResolutionHost)); + + for (const resolution of resolutions) { + if (resolution.resolvedTypeReferenceDirective?.resolvedFileName && !hostProject.getCurrentProgram()!.getSourceFile(resolution.resolvedTypeReferenceDirective.resolvedFileName)) { + rootNames = append(rootNames, resolution.resolvedTypeReferenceDirective.resolvedFileName); + } + } + } + + return rootNames || ts.emptyArray; + + function addDependency(dependency: string) { + if (!startsWith(dependency, "@types/")) { + (dependencyNames || (dependencyNames = createMap())).set(dependency, true); + } + } + } + + /*@internal*/ + static create(dependencySelection: PackageJsonAutoImportPreference, hostProject: Project, moduleResolutionHost: ModuleResolutionHost, documentRegistry: DocumentRegistry): AutoImportProviderProject | undefined { + if (dependencySelection === PackageJsonAutoImportPreference.None) { + return undefined; + } + + const compilerOptions: CompilerOptions = { + ...hostProject.getCompilerOptions(), + noLib: true, + diagnostics: false, + skipLibCheck: true, + types: ts.emptyArray, + lib: ts.emptyArray, + sourceMap: false + }; + + const rootNames = this.getRootFileNames(dependencySelection, hostProject, moduleResolutionHost, compilerOptions); + if (!rootNames.length) { + return undefined; + } + + return new AutoImportProviderProject(hostProject, rootNames, documentRegistry, compilerOptions); + } + + private rootFileNames: string[] | undefined; + + /*@internal*/ + constructor( + private hostProject: Project, + initialRootNames: string[], + documentRegistry: DocumentRegistry, + compilerOptions: CompilerOptions, + ) { + super(AutoImportProviderProject.newName(), + ProjectKind.AutoImportProvider, + hostProject.projectService, + documentRegistry, + /*hasExplicitListOfFiles*/ false, + /*lastFileExceededProgramSize*/ undefined, + compilerOptions, + /*compileOnSaveEnabled*/ false, + hostProject.getWatchOptions(), + hostProject.projectService.host, + hostProject.currentDirectory); + + this.rootFileNames = initialRootNames; + } + + isOrphan() { + return true; + } + + updateGraph() { + let rootFileNames = this.rootFileNames; + if (!rootFileNames) { + rootFileNames = AutoImportProviderProject.getRootFileNames( + this.hostProject.includePackageJsonAutoImports(), + this.hostProject, + this.projectService.host, + this.getCompilationSettings()); + } + + this.projectService.setFileNamesOfAutoImportProviderProject(this, rootFileNames); + this.rootFileNames = rootFileNames; + this.hostProject.getImportSuggestionsCache().clear(); + return super.updateGraph(); + } + + markAsDirty() { + this.rootFileNames = undefined; + super.markAsDirty(); + } + + getScriptFileNames() { + return this.rootFileNames || ts.emptyArray; + } + + getLanguageService(): never { + throw new Error("AutoImportProviderProject language service should never be used. To get the program, use `project.getCurrentProgram()`."); + } + + markAutoImportProviderAsDirty(): never { + throw new Error("AutoImportProviderProject is an auto import provider; use `markAsDirty()` instead."); + } + + /*@internal*/ + includePackageJsonAutoImports() { + return PackageJsonAutoImportPreference.None; + } + + getTypeAcquisition(): TypeAcquisition { + return { enable: false }; + } + } + /** * If a file is opened, the server will look for a tsconfig (or jsconfig) * and if successful create a ConfiguredProject for it. diff --git a/src/server/protocol.ts b/src/server/protocol.ts index ab19b64439cae..de974058fa167 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -248,6 +248,10 @@ namespace ts.server.protocol { * Time spent updating the program graph, in milliseconds. */ updateGraphDurationMs?: number; + /** + * The time spent creating or updating the auto-import program, in milliseconds. + */ + createAutoImportProviderProgramDurationMs?: number; } /** @@ -2159,6 +2163,11 @@ namespace ts.server.protocol { * and therefore may not be accurate. */ isFromUncheckedFile?: true; + /** + * If true, this completion was for an auto-import of a module not yet in the program, but listed + * in the project package.json. + */ + isPackageJsonImport?: true; } /** @@ -3178,6 +3187,7 @@ namespace ts.server.protocol { readonly lazyConfiguredProjectsFromExternalProject?: boolean; readonly providePrefixAndSuffixTextForRename?: boolean; readonly allowRenameOfImportPath?: boolean; + readonly includePackageJsonAutoImports?: "exclude-dev" | "all" | "none"; } export interface CompilerOptions { diff --git a/src/server/scriptInfo.ts b/src/server/scriptInfo.ts index 335e283276c10..76976819c6659 100644 --- a/src/server/scriptInfo.ts +++ b/src/server/scriptInfo.ts @@ -491,7 +491,7 @@ namespace ts.server { case 0: return Errors.ThrowNoProject(); case 1: - return this.containingProjects[0]; + return ensureNotAutoImportProvider(this.containingProjects[0]); default: // If this file belongs to multiple projects, below is the order in which default project is used // - for open script info, its default configured project during opening is default if info is part of it @@ -501,6 +501,7 @@ namespace ts.server { // - first inferred project let firstExternalProject: ExternalProject | undefined; let firstConfiguredProject: ConfiguredProject | undefined; + let firstInferredProject: InferredProject | undefined; let firstNonSourceOfProjectReferenceRedirect: ConfiguredProject | undefined; let defaultConfiguredProject: ConfiguredProject | false | undefined; for (let index = 0; index < this.containingProjects.length; index++) { @@ -521,12 +522,15 @@ namespace ts.server { else if (!firstExternalProject && isExternalProject(project)) { firstExternalProject = project; } + else if (!firstInferredProject && isInferredProject(project)) { + firstInferredProject = project; + } } - return defaultConfiguredProject || + return ensureNotAutoImportProvider(defaultConfiguredProject || firstNonSourceOfProjectReferenceRedirect || firstConfiguredProject || firstExternalProject || - this.containingProjects[0]; + firstInferredProject); } } @@ -607,6 +611,11 @@ namespace ts.server { return !forEach(this.containingProjects, p => !p.isOrphan()); } + /*@internal*/ + isContainedByAutoImportProvider() { + return some(this.containingProjects, p => p.projectKind === ProjectKind.AutoImportProvider); + } + /** * @param line 1 based index */ @@ -650,6 +659,13 @@ namespace ts.server { } } + function ensureNotAutoImportProvider(project: Project | undefined) { + if (!project || project.projectKind === ProjectKind.AutoImportProvider) { + return Errors.ThrowNoProject(); + } + return project; + } + function failIfInvalidPosition(position: number) { Debug.assert(typeof position === "number", `Expected position ${position} to be a number.`); Debug.assert(position >= 0, `Expected position to be non-negative.`); diff --git a/src/server/session.ts b/src/server/session.ts index 6aecc217b1c13..f233ef5c0073f 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -644,7 +644,7 @@ namespace ts.server { protected projectService: ProjectService; private changeSeq = 0; - private updateGraphDurationMs: number | undefined; + private performanceData: protocol.PerformanceData | undefined; private currentRequestId!: number; private errorCheck: MultistepOperation; @@ -720,12 +720,21 @@ namespace ts.server { this.event({ request_seq: requestId }, "requestCompleted"); } + private addPerformanceData(key: keyof protocol.PerformanceData, value: number) { + if (!this.performanceData) { + this.performanceData = {}; + } + this.performanceData[key] = (this.performanceData[key] ?? 0) + value; + } + private performanceEventHandler(event: PerformanceEvent) { switch (event.kind) { - case "UpdateGraph": { - this.updateGraphDurationMs = (this.updateGraphDurationMs || 0) + event.durationMs; + case "UpdateGraph": + this.addPerformanceData("updateGraphDurationMs", event.durationMs); + break; + case "CreatePackageJsonAutoImportProvider": + this.addPerformanceData("createAutoImportProviderProgramDurationMs", event.durationMs); break; - } } } @@ -867,11 +876,7 @@ namespace ts.server { command: cmdName, request_seq: reqSeq, success, - performanceData: !this.updateGraphDurationMs - ? undefined - : { - updateGraphDurationMs: this.updateGraphDurationMs, - }, + performanceData: this.performanceData }; if (success) { @@ -1710,10 +1715,10 @@ namespace ts.server { const prefix = args.prefix || ""; const entries = mapDefined(completions.entries, entry => { if (completions.isMemberCompletion || startsWith(entry.name.toLowerCase(), prefix.toLowerCase())) { - const { name, kind, kindModifiers, sortText, insertText, replacementSpan, hasAction, source, isRecommended } = entry; + const { name, kind, kindModifiers, sortText, insertText, replacementSpan, hasAction, source, isRecommended, isPackageJsonImport } = 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 }; + return { name, kind, kindModifiers, sortText, insertText, replacementSpan: convertedSpan, hasAction: hasAction || undefined, source, isRecommended, isPackageJsonImport }; } }).sort((a, b) => compareStringsCaseSensitiveUI(a.name, b.name)); @@ -2738,7 +2743,7 @@ namespace ts.server { public onMessage(message: string) { this.gcTimer.scheduleCollect(); - this.updateGraphDurationMs = undefined; + this.performanceData = undefined; let start: number[] | undefined; if (this.logger.hasLevel(LogLevel.requestTime)) { diff --git a/src/server/utilitiesPublic.ts b/src/server/utilitiesPublic.ts index 676435dbc916b..5e9ba6b973223 100644 --- a/src/server/utilitiesPublic.ts +++ b/src/server/utilitiesPublic.ts @@ -117,6 +117,11 @@ namespace ts.server { return `/dev/null/inferredProject${counter}*`; } + /*@internal*/ + export function makeAutoImportProviderProjectName(counter: number) { + return `/dev/null/autoImportProviderProject${counter}*`; + } + export function createSortedArray(): SortedArray { return [] as any as SortedArray; // TODO: GH#19873 } diff --git a/src/services/codefixes/importFixes.ts b/src/services/codefixes/importFixes.ts index 4de3035f09f8b..e57d9fae6ce90 100644 --- a/src/services/codefixes/importFixes.ts +++ b/src/services/codefixes/importFixes.ts @@ -16,7 +16,7 @@ namespace ts.codefix { errorCodes, getCodeActions(context) { const { errorCode, preferences, sourceFile, span } = context; - const info = getFixesInfo(context, errorCode, span.start); + const info = getFixesInfo(context, errorCode, span.start, /*useAutoImportProvider*/ true); if (!info) return undefined; const { fixes, symbolName } = info; const quotePreference = getQuotePreference(sourceFile, preferences); @@ -25,7 +25,7 @@ namespace ts.codefix { fixIds: [importFixId], getAllCodeActions: context => { const { sourceFile, program, preferences, host } = context; - const importAdder = createImportAdder(sourceFile, program, preferences, host); + const importAdder = createImportAdderWorker(sourceFile, program, /*useAutoImportProvider*/ true, preferences, host); eachDiagnostic(context, errorCodes, diag => importAdder.addImportFromDiagnostic(diag, context)); return createCombinedCodeActions(textChanges.ChangeTracker.with(context, importAdder.writeFixes)); }, @@ -38,6 +38,10 @@ namespace ts.codefix { } export function createImportAdder(sourceFile: SourceFile, program: Program, preferences: UserPreferences, host: LanguageServiceHost): ImportAdder { + return createImportAdderWorker(sourceFile, program, /*useAutoImportProvider*/ false, preferences, host); + } + + function createImportAdderWorker(sourceFile: SourceFile, program: Program, useAutoImportProvider: boolean, preferences: UserPreferences, host: LanguageServiceHost): ImportAdder { const compilerOptions = program.getCompilerOptions(); // Namespace fixes don't conflict, so just build a list. const addToNamespace: FixUseNamespaceImport[] = []; @@ -48,7 +52,7 @@ namespace ts.codefix { return { addImportFromDiagnostic, addImportFromExportedSymbol, writeFixes }; function addImportFromDiagnostic(diagnostic: DiagnosticWithLocation, context: CodeFixContextBase) { - const info = getFixesInfo(context, diagnostic.code, diagnostic.start); + const info = getFixesInfo(context, diagnostic.code, diagnostic.start, useAutoImportProvider); if (!info || !info.fixes.length) return; addImport(info); } @@ -58,7 +62,7 @@ namespace ts.codefix { const symbolName = getNameForExportedSymbol(exportedSymbol, getEmitScriptTarget(compilerOptions)); const checker = program.getTypeChecker(); const symbol = checker.getMergedSymbol(skipAlias(exportedSymbol, checker)); - const exportInfos = getAllReExportingModules(sourceFile, symbol, moduleSymbol, symbolName, sourceFile, compilerOptions, checker, program.getSourceFiles()); + const exportInfos = getAllReExportingModules(sourceFile, symbol, moduleSymbol, symbolName, host, program, useAutoImportProvider); const preferTypeOnlyImport = !!usageIsTypeOnly && compilerOptions.importsNotUsedAsValues === ImportsNotUsedAsValues.Error; const useRequire = shouldUseRequire(sourceFile, compilerOptions); const fix = getImportFixForSymbol(sourceFile, exportInfos, moduleSymbol, symbolName, program, /*position*/ undefined, preferTypeOnlyImport, useRequire, host, preferences); @@ -206,7 +210,7 @@ namespace ts.codefix { preferences: UserPreferences, ): { readonly moduleSpecifier: string, readonly codeAction: CodeAction } { const compilerOptions = program.getCompilerOptions(); - const exportInfos = getAllReExportingModules(sourceFile, exportedSymbol, moduleSymbol, symbolName, sourceFile, compilerOptions, program.getTypeChecker(), program.getSourceFiles()); + const exportInfos = getAllReExportingModules(sourceFile, exportedSymbol, moduleSymbol, symbolName, host, program, /*useAutoImportProvider*/ true); const useRequire = shouldUseRequire(sourceFile, compilerOptions); const preferTypeOnlyImport = compilerOptions.importsNotUsedAsValues === ImportsNotUsedAsValues.Error && !isSourceFileJS(sourceFile) && isValidTypeOnlyAliasUseSite(getTokenAtPosition(sourceFile, position)); const moduleSpecifier = first(getNewImportInfos(program, sourceFile, position, preferTypeOnlyImport, useRequire, exportInfos, host, preferences)).moduleSpecifier; @@ -224,11 +228,13 @@ namespace ts.codefix { return { description, changes, commands }; } - function getAllReExportingModules(importingFile: SourceFile, exportedSymbol: Symbol, exportingModuleSymbol: Symbol, symbolName: string, sourceFile: SourceFile, compilerOptions: CompilerOptions, checker: TypeChecker, allSourceFiles: readonly SourceFile[]): readonly SymbolExportInfo[] { + function getAllReExportingModules(importingFile: SourceFile, exportedSymbol: Symbol, exportingModuleSymbol: Symbol, symbolName: string, host: LanguageServiceHost, program: Program, useAutoImportProvider: boolean): readonly SymbolExportInfo[] { const result: SymbolExportInfo[] = []; - forEachExternalModule(checker, allSourceFiles, (moduleSymbol, moduleFile) => { + const compilerOptions = program.getCompilerOptions(); + forEachExternalModuleToImportFrom(program, host, importingFile, /*filterByPackageJson*/ false, useAutoImportProvider, (moduleSymbol, moduleFile, program) => { + const checker = program.getTypeChecker(); // Don't import from a re-export when looking "up" like to `./index` or `../index`. - if (moduleFile && moduleSymbol !== exportingModuleSymbol && startsWith(sourceFile.fileName, getDirectoryPath(moduleFile.fileName))) { + if (moduleFile && moduleSymbol !== exportingModuleSymbol && startsWith(importingFile.fileName, getDirectoryPath(moduleFile.fileName))) { return; } @@ -424,11 +430,11 @@ namespace ts.codefix { } interface FixesInfo { readonly fixes: readonly ImportFix[]; readonly symbolName: string; } - function getFixesInfo(context: CodeFixContextBase, errorCode: number, pos: number): FixesInfo | undefined { + function getFixesInfo(context: CodeFixContextBase, errorCode: number, pos: number, useAutoImportProvider: boolean): FixesInfo | undefined { const symbolToken = getTokenAtPosition(context.sourceFile, pos); 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) : undefined; + : isIdentifier(symbolToken) ? getFixesInfoForNonUMDImport(context, symbolToken, useAutoImportProvider) : undefined; return info && { ...info, fixes: sort(info.fixes, (a, b) => a.kind - b.kind) }; } @@ -483,7 +489,7 @@ namespace ts.codefix { } } - function getFixesInfoForNonUMDImport({ sourceFile, program, cancellationToken, host, preferences }: CodeFixContextBase, symbolToken: Identifier): FixesInfo | undefined { + function getFixesInfoForNonUMDImport({ sourceFile, program, cancellationToken, host, preferences }: CodeFixContextBase, symbolToken: Identifier, useAutoImportProvider: boolean): FixesInfo | undefined { const checker = program.getTypeChecker(); // If we're at ``, we must check if `Foo` is already in scope, and if so, get an import for `React` instead. const symbolName = isJsxOpeningLikeElement(symbolToken.parent) @@ -497,7 +503,7 @@ namespace ts.codefix { const compilerOptions = program.getCompilerOptions(); const preferTypeOnlyImport = compilerOptions.importsNotUsedAsValues === ImportsNotUsedAsValues.Error && isValidTypeOnlyAliasUseSite(symbolToken); const useRequire = shouldUseRequire(sourceFile, compilerOptions); - const exportInfos = getExportInfos(symbolName, getMeaningFromLocation(symbolToken), cancellationToken, sourceFile, checker, program, host); + const exportInfos = getExportInfos(symbolName, getMeaningFromLocation(symbolToken), cancellationToken, sourceFile, checker, program, useAutoImportProvider, host); const fixes = arrayFrom(flatMapIterator(exportInfos.entries(), ([_, exportInfos]) => getFixForImport(exportInfos, symbolName, symbolToken.getStart(sourceFile), preferTypeOnlyImport, useRequire, program, sourceFile, host, preferences))); return { fixes, symbolName }; @@ -511,6 +517,7 @@ namespace ts.codefix { sourceFile: SourceFile, checker: TypeChecker, program: Program, + useAutoImportProvider: boolean, host: LanguageServiceHost ): ReadonlyMap { // For each original symbol, keep all re-exports of that symbol together so we can call `getCodeActionsForImport` on the whole group at once. @@ -519,7 +526,7 @@ namespace ts.codefix { function addSymbol(moduleSymbol: Symbol, exportedSymbol: Symbol, importKind: ImportKind): void { originalSymbolToExportInfos.add(getUniqueSymbolId(exportedSymbol, checker).toString(), { moduleSymbol, importKind, exportedSymbolIsTypeOnly: isTypeOnlySymbol(exportedSymbol, checker) }); } - forEachExternalModuleToImportFrom(program, host, sourceFile, /*filterByPackageJson*/ true, moduleSymbol => { + forEachExternalModuleToImportFrom(program, host, sourceFile, /*filterByPackageJson*/ true, useAutoImportProvider, moduleSymbol => { cancellationToken.throwIfCancellationRequested(); const defaultInfo = getDefaultLikeExportInfo(sourceFile, moduleSymbol, checker, program.getCompilerOptions()); @@ -790,7 +797,24 @@ namespace ts.codefix { host: LanguageServiceHost, from: SourceFile, filterByPackageJson: boolean, - cb: (module: Symbol) => void, + 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)); + const autoImportProvider = useAutoImportProvider && host.getPackageJsonAutoImportProvider?.(); + if (autoImportProvider) { + const start = timestamp(); + forEachExternalModuleToImportFromInProgram(autoImportProvider, host, from, filterByPackageJson, (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); @@ -798,7 +822,7 @@ namespace ts.codefix { forEachExternalModule(program.getTypeChecker(), program.getSourceFiles(), (module, sourceFile) => { if (sourceFile === undefined) { if (!packageJson || packageJson.allowsImportingAmbientModule(module)) { - cb(module); + cb(module, sourceFile); } else if (packageJson) { filteredCount++; @@ -809,16 +833,14 @@ namespace ts.codefix { isImportableFile(program, from, sourceFile, moduleSpecifierResolutionHost) ) { if (!packageJson || packageJson.allowsImportingSourceFile(sourceFile)) { - cb(module); + cb(module, sourceFile); } else if (packageJson) { filteredCount++; } } }); - if (host.log) { - host.log(`forEachExternalModuleToImportFrom: filtered out ${filteredCount} modules by package.json contents`); - } + 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) { @@ -900,8 +922,9 @@ namespace ts.codefix { } function createAutoImportFilter(fromFile: SourceFile, program: Program, host: LanguageServiceHost, moduleSpecifierResolutionHost = createModuleSpecifierResolutionHost(program, host)) { - const packageJsons = host.getPackageJsonsVisibleToFile && host.getPackageJsonsVisibleToFile(fromFile.fileName) || getPackageJsonsVisibleToFile(fromFile.fileName, host); - const dependencyGroups = PackageJsonDependencyGroup.Dependencies | PackageJsonDependencyGroup.DevDependencies | PackageJsonDependencyGroup.OptionalDependencies; + 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 }; @@ -909,7 +932,7 @@ namespace ts.codefix { function moduleSpecifierIsCoveredByPackageJson(specifier: string) { const packageName = getNodeModuleRootSpecifier(specifier); for (const packageJson of packageJsons) { - if (packageJson.has(packageName, dependencyGroups) || packageJson.has(getTypesPackageName(packageName), dependencyGroups)) { + if (packageJson.has(packageName) || packageJson.has(getTypesPackageName(packageName))) { return true; } } diff --git a/src/services/completions.ts b/src/services/completions.ts index 2eb444d54b7d5..a5e24c425e3a3 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -46,6 +46,7 @@ namespace ts.Completions { kind: SymbolOriginInfoKind; moduleSymbol: Symbol; isDefaultExport: boolean; + isFromPackageJson?: boolean; } function originIsThisType(origin: SymbolOriginInfo): boolean { @@ -60,6 +61,10 @@ namespace ts.Completions { return !!(origin && origin.kind & SymbolOriginInfoKind.Export); } + function originIsPackageJsonImport(origin: SymbolOriginInfo | undefined): origin is SymbolOriginInfoExport { + return originIsExport(origin) && !!origin.isFromPackageJson; + } + function originIsPromise(origin: SymbolOriginInfo): boolean { return !!(origin.kind & SymbolOriginInfoKind.Promise); } @@ -159,7 +164,7 @@ namespace ts.Completions { sourceFile: SourceFile, position: number, preferences: UserPreferences, - triggerCharacter: CompletionsTriggerCharacter | undefined, + triggerCharacter: CompletionsTriggerCharacter | undefined ): CompletionInfo | undefined { const typeChecker = program.getTypeChecker(); const compilerOptions = program.getCompilerOptions(); @@ -437,6 +442,7 @@ namespace ts.Completions { isRecommended: isRecommendedCompletionMatch(symbol, recommendedCompletion, typeChecker) || undefined, insertText, replacementSpan, + isPackageJsonImport: originIsPackageJsonImport(origin) || undefined, }; } @@ -834,7 +840,7 @@ namespace ts.Completions { position: number, preferences: Pick, detailsEntryId: CompletionEntryIdentifier | undefined, - host: LanguageServiceHost, + host: LanguageServiceHost ): CompletionData | Request | undefined { const typeChecker = program.getTypeChecker(); @@ -1607,18 +1613,19 @@ namespace ts.Completions { /** Bucket B */ const aliasesToAlreadyIncludedSymbols = createMap(); /** Bucket C */ - const aliasesToReturnIfOriginalsAreMissing = createMap<{ alias: Symbol, moduleSymbol: Symbol }>(); + const aliasesToReturnIfOriginalsAreMissing = createMap<{ alias: Symbol, moduleSymbol: Symbol, isFromPackageJson: boolean }>(); /** Bucket A */ const results: AutoImportSuggestion[] = []; /** Ids present in `results` for faster lookup */ const resultSymbolIds = createMap(); - codefix.forEachExternalModuleToImportFrom(program, host, sourceFile, !detailsEntryId, moduleSymbol => { + codefix.forEachExternalModuleToImportFrom(program, host, sourceFile, !detailsEntryId, /*useAutoImportProvider*/ true, (moduleSymbol, _, 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))) { @@ -1628,7 +1635,7 @@ namespace ts.Completions { // 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, moduleSymbol, /*skipFilter*/ true); + pushSymbol(resolvedModuleSymbol, moduleSymbol, isFromPackageJson, /*skipFilter*/ true); } for (const symbol of typeChecker.getExportsAndPropertiesOfModule(moduleSymbol)) { @@ -1653,7 +1660,7 @@ namespace ts.Completions { const nearestExportSymbolId = getSymbolId(nearestExportSymbol).toString(); const symbolHasBeenSeen = resultSymbolIds.has(nearestExportSymbolId) || aliasesToAlreadyIncludedSymbols.has(nearestExportSymbolId); if (!symbolHasBeenSeen) { - aliasesToReturnIfOriginalsAreMissing.set(nearestExportSymbolId, { alias: symbol, moduleSymbol }); + aliasesToReturnIfOriginalsAreMissing.set(nearestExportSymbolId, { alias: symbol, moduleSymbol, isFromPackageJson }); aliasesToAlreadyIncludedSymbols.set(symbolId, true); } else { @@ -1665,18 +1672,18 @@ namespace ts.Completions { else { // This is not a re-export, so see if we have any aliases pending and remove them (step 3 in diagrammed example) aliasesToReturnIfOriginalsAreMissing.delete(symbolId); - pushSymbol(symbol, moduleSymbol); + pushSymbol(symbol, moduleSymbol, isFromPackageJson, /*skipFilter*/ false); } } }); // By this point, any potential duplicates that were actually duplicates have been // removed, so the rest need to be added. (Step 4 in diagrammed example) - aliasesToReturnIfOriginalsAreMissing.forEach(({ alias, moduleSymbol }) => pushSymbol(alias, moduleSymbol)); + aliasesToReturnIfOriginalsAreMissing.forEach(({ alias, moduleSymbol, isFromPackageJson }) => pushSymbol(alias, moduleSymbol, isFromPackageJson, /*skipFilter*/ false)); log(`getSymbolsFromOtherSourceFileExports: ${timestamp() - startTime}`); return results; - function pushSymbol(symbol: Symbol, moduleSymbol: Symbol, skipFilter = false) { + function pushSymbol(symbol: Symbol, moduleSymbol: Symbol, isFromPackageJson: boolean, skipFilter: boolean) { const isDefaultExport = symbol.escapedName === InternalSymbolName.Default; if (isDefaultExport) { symbol = getLocalSymbolForExportDefault(symbol) || symbol; @@ -1685,7 +1692,7 @@ namespace ts.Completions { return; } addToSeen(resultSymbolIds, getSymbolId(symbol)); - const origin: SymbolOriginInfoExport = { kind: SymbolOriginInfoKind.Export, moduleSymbol, isDefaultExport }; + const origin: SymbolOriginInfoExport = { kind: SymbolOriginInfoKind.Export, moduleSymbol, isDefaultExport, isFromPackageJson }; results.push({ symbol, symbolName: getNameForExportedSymbol(symbol, target), diff --git a/src/services/services.ts b/src/services/services.ts index 92d8336d2f667..b409a97516efb 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -1266,7 +1266,6 @@ namespace ts { // Get a fresh cache of the host information let hostCache: HostCache | undefined = new HostCache(host, getCanonicalFileName); const rootFileNames = hostCache.getRootFileNames(); - const hasInvalidatedResolution: HasInvalidatedResolution = host.hasInvalidatedResolution || returnFalse; const hasChangedAutomaticTypeDirectiveNames = maybeBind(host, host.hasChangedAutomaticTypeDirectiveNames); const projectReferences = hostCache.getProjectReferences(); @@ -1442,6 +1441,10 @@ namespace ts { return program; } + function getAutoImportProvider(): Program | undefined { + return host.getPackageJsonAutoImportProvider?.(); + } + function cleanupSemanticCache(): void { program = undefined!; // TODO: GH#18217 } @@ -2263,6 +2266,7 @@ namespace ts { getEmitOutput, getNonBoundSourceFile, getProgram, + getAutoImportProvider, getApplicableRefactors, getEditsForRefactor, toLineColumnOffset: sourceMapper.toLineColumnOffset, diff --git a/src/services/types.ts b/src/services/types.ts index 006a6355c72d9..d90eac56e5de6 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -195,6 +195,7 @@ namespace ts { /* @internal */ export interface PackageJsonInfo { fileName: string; + parseable: boolean; dependencies?: Map; devDependencies?: Map; peerDependencies?: Map; @@ -203,11 +204,23 @@ namespace ts { has(dependencyName: string, inGroups?: PackageJsonDependencyGroup): boolean; } - /** @internal */ + /* @internal */ export interface FormattingHost { getNewLine?(): string; } + /* @internal */ + export const enum PackageJsonAutoImportPreference { + None, + ExcludeDevDependencies, + All + } + + export interface PerformanceEvent { + kind: "UpdateGraph" | "CreatePackageJsonAutoImportProvider"; + durationMs: number; + } + // // Public interface of the host of a language service instance. // @@ -282,11 +295,17 @@ namespace ts { /* @internal */ getPackageJsonsVisibleToFile?(fileName: string, rootDir?: string): readonly PackageJsonInfo[]; /* @internal */ + getPackageJsonsForAutoImport?(rootDir?: string): readonly PackageJsonInfo[]; + /* @internal */ getImportSuggestionsCache?(): Completions.ImportSuggestionsForFileCache; /* @internal */ setCompilerHost?(host: CompilerHost): void; /* @internal */ useSourceOfProjectReferenceRedirect?(): boolean; + /* @internal */ + getPackageJsonAutoImportProvider?(): Program | undefined; + /* @internal */ + sendPerformanceEvent?(kind: PerformanceEvent["kind"], durationMs: number): void; } /* @internal */ @@ -485,6 +504,7 @@ namespace ts { getProgram(): Program | undefined; /* @internal */ getNonBoundSourceFile(fileName: string): SourceFile; + /* @internal */ getAutoImportProvider(): Program | undefined; dispose(): void; } @@ -1081,6 +1101,7 @@ namespace ts { source?: string; isRecommended?: true; isFromUncheckedFile?: true; + isPackageJsonImport?: true; } export interface CompletionEntryDetails { diff --git a/src/services/utilities.ts b/src/services/utilities.ts index 5b9c2b1738016..d3b96994f61e9 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -2129,15 +2129,10 @@ namespace ts { return !!location.parent && isImportOrExportSpecifier(location.parent) && location.parent.propertyName === location; } - export function scriptKindIs(fileName: string, host: LanguageServiceHost, ...scriptKinds: ScriptKind[]): boolean { - const scriptKind = getScriptKind(fileName, host); - return some(scriptKinds, k => k === scriptKind); - } - - export function getScriptKind(fileName: string, host?: LanguageServiceHost): ScriptKind { + export function getScriptKind(fileName: string, host: LanguageServiceHost): ScriptKind { // First check to see if the script kind was specified by the host. Chances are the host // may override the default script kind for the file extension. - return ensureScriptKind(fileName, host && host.getScriptKind && host.getScriptKind(fileName)); + return ensureScriptKind(fileName, host.getScriptKind && host.getScriptKind(fileName)); } export function getSymbolTarget(symbol: Symbol, checker: TypeChecker): Symbol { @@ -2683,7 +2678,7 @@ namespace ts { return packageJsons; } - export function createPackageJsonInfo(fileName: string, host: LanguageServiceHost): PackageJsonInfo | false | undefined { + export function createPackageJsonInfo(fileName: string, host: { readFile?(fileName: string): string | undefined }): PackageJsonInfo | undefined { if (!host.readFile) { return undefined; } @@ -2693,19 +2688,20 @@ namespace ts { const stringContent = host.readFile(fileName); if (!stringContent) return undefined; - const content = tryParseJson(stringContent) as PackageJsonRaw; - if (!content) return false; + const content = tryParseJson(stringContent) as PackageJsonRaw | undefined; const info: Pick = {}; - for (const key of dependencyKeys) { - const dependencies = content[key]; - if (!dependencies) { - continue; - } - const dependencyMap = createMap(); - for (const packageName in dependencies) { - dependencyMap.set(packageName, dependencies[packageName]); + if (content) { + for (const key of dependencyKeys) { + const dependencies = content[key]; + if (!dependencies) { + continue; + } + const dependencyMap = createMap(); + for (const packageName in dependencies) { + dependencyMap.set(packageName, dependencies[packageName]); + } + info[key] = dependencyMap; } - info[key] = dependencyMap; } const dependencyGroups = [ @@ -2717,6 +2713,7 @@ namespace ts { return { ...info, + parseable: !!content, fileName, get, has(dependencyName, inGroups) { diff --git a/src/testRunner/tsconfig.json b/src/testRunner/tsconfig.json index f17f10789f03b..dcdfda24136d1 100644 --- a/src/testRunner/tsconfig.json +++ b/src/testRunner/tsconfig.json @@ -143,6 +143,7 @@ "unittests/tscWatch/watchApi.ts", "unittests/tscWatch/watchEnvironment.ts", "unittests/tsserver/applyChangesToOpenFiles.ts", + "unittests/tsserver/autoImportProvider.ts", "unittests/tsserver/cachingFileSystemInformation.ts", "unittests/tsserver/cancellationToken.ts", "unittests/tsserver/compileOnSave.ts", diff --git a/src/testRunner/unittests/services/extract/helpers.ts b/src/testRunner/unittests/services/extract/helpers.ts index a47875299946d..2c05326a6dd87 100644 --- a/src/testRunner/unittests/services/extract/helpers.ts +++ b/src/testRunner/unittests/services/extract/helpers.ts @@ -86,7 +86,7 @@ namespace ts { function runBaseline(extension: Extension) { const path = "/a" + extension; - const program = makeProgram({ path, content: t.source }, includeLib); + const { program } = makeProgram({ path, content: t.source }, includeLib); if (hasSyntacticDiagnostics(program)) { // Don't bother generating JS baselines for inputs that aren't valid JS. @@ -121,7 +121,7 @@ namespace ts { const newTextWithRename = newText.slice(0, renameLocation) + "/*RENAME*/" + newText.slice(renameLocation); data.push(newTextWithRename); - const diagProgram = makeProgram({ path, content: newText }, includeLib); + const { program: diagProgram } = makeProgram({ path, content: newText }, includeLib); assert.isFalse(hasSyntacticDiagnostics(diagProgram)); } Harness.Baseline.runBaseline(`${baselineFolder}/${caption}${extension}`, data.join(newLineCharacter)); @@ -132,7 +132,8 @@ namespace ts { const projectService = projectSystem.createProjectService(host); projectService.openClientFile(f.path); const program = projectService.inferredProjects[0].getLanguageService().getProgram()!; - return program; + const autoImportProvider = projectService.inferredProjects[0].getLanguageService().getAutoImportProvider(); + return { program, autoImportProvider }; } function hasSyntacticDiagnostics(program: Program) { diff --git a/src/testRunner/unittests/tsserver/autoImportProvider.ts b/src/testRunner/unittests/tsserver/autoImportProvider.ts new file mode 100644 index 0000000000000..031af230c5239 --- /dev/null +++ b/src/testRunner/unittests/tsserver/autoImportProvider.ts @@ -0,0 +1,305 @@ +namespace ts.projectSystem { + const angularFormsDts: File = { + path: "/node_modules/@angular/forms/forms.d.ts", + content: "export declare class PatternValidator {}", + }; + const angularFormsPackageJson: File = { + path: "/node_modules/@angular/forms/package.json", + content: `{ "name": "@angular/forms", "typings": "./forms.d.ts" }`, + }; + const angularCoreDts: File = { + path: "/node_modules/@angular/core/core.d.ts", + content: "", + }; + const angularCorePackageJson: File = { + path: "/node_modules/@angular/core/package.json", + content: `{ "name": "@angular/core", "typings": "./core.d.ts" }`, + }; + const tsconfig: File = { + path: "/tsconfig.json", + content: `{ "compilerOptions": { "module": "commonjs" } }`, + }; + const packageJson: File = { + path: "/package.json", + content: `{ "dependencies": { "@angular/forms": "*", "@angular/core": "*" } }` + }; + const indexTs: File = { + path: "/index.ts", + content: "" + }; + + describe("unittests:: tsserver:: autoImportProvider", () => { + it("Auto import provider program is not created without dependencies listed in package.json", () => { + const { projectService, session } = setup([ + angularFormsDts, + angularFormsPackageJson, + tsconfig, + { path: packageJson.path, content: `{ "dependencies": {} }` }, + indexTs + ]); + openFilesForSession([indexTs], session); + assert.isUndefined(projectService.configuredProjects.get(tsconfig.path)!.getLanguageService().getAutoImportProvider()); + }); + + it("Auto import provider program is not created if dependencies are already in main program", () => { + const { projectService, session } = setup([ + angularFormsDts, + angularFormsPackageJson, + tsconfig, + packageJson, + { path: indexTs.path, content: "import '@angular/forms';" } + ]); + openFilesForSession([indexTs], session); + assert.isUndefined(projectService.configuredProjects.get(tsconfig.path)!.getLanguageService().getAutoImportProvider()); + }); + + it("Auto-import program is not created for projects already inside node_modules", () => { + // Simulate browsing typings files inside node_modules: no point creating auto import program + // for the InferredProject that gets created in there. + const { projectService, session } = setup([ + angularFormsDts, + { path: angularFormsPackageJson.path, content: `{ "dependencies": { "@angular/core": "*" } }` }, + { path: "/node_modules/@angular/core/package.json", content: `{ "typings": "./core.d.ts" }` }, + { path: "/node_modules/@angular/core/core.d.ts", content: `export namespace angular {};` }, + ]); + + openFilesForSession([angularFormsDts], session); + checkNumberOfInferredProjects(projectService, 1); + checkNumberOfConfiguredProjects(projectService, 0); + assert.isUndefined(projectService + .getDefaultProjectForFile(angularFormsDts.path as server.NormalizedPath, /*ensureProject*/ true)! + .getLanguageService() + .getAutoImportProvider()); + }); + + it("Auto-importable file is in inferred project until imported", () => { + const { projectService, session, updateFile } = setup([angularFormsDts, angularFormsPackageJson, tsconfig, packageJson, indexTs]); + checkNumberOfInferredProjects(projectService, 0); + openFilesForSession([angularFormsDts], session); + checkNumberOfInferredProjects(projectService, 1); + assert.equal( + projectService.getDefaultProjectForFile(angularFormsDts.path as server.NormalizedPath, /*ensureProject*/ true)?.projectKind, + server.ProjectKind.Inferred); + + updateFile(indexTs.path, "import '@angular/forms'"); + assert.equal( + projectService.getDefaultProjectForFile(angularFormsDts.path as server.NormalizedPath, /*ensureProject*/ true)?.projectKind, + server.ProjectKind.Configured); + + assert.isUndefined(projectService.configuredProjects.get(tsconfig.path)!.getLanguageService().getAutoImportProvider()); + }); + + it("Responds to package.json changes", () => { + const { projectService, session, host } = setup([ + angularFormsDts, + angularFormsPackageJson, + tsconfig, + { path: "/package.json", content: "{}" }, + indexTs + ]); + + openFilesForSession([indexTs], session); + assert.isUndefined(projectService.configuredProjects.get(tsconfig.path)!.getLanguageService().getAutoImportProvider()); + + host.writeFile(packageJson.path, packageJson.content); + assert.ok(projectService.configuredProjects.get(tsconfig.path)!.getLanguageService().getAutoImportProvider()); + }); + + it("Reuses autoImportProvider when program structure is unchanged", () => { + const { projectService, session, updateFile } = setup([ + angularFormsDts, + angularFormsPackageJson, + tsconfig, + packageJson, + indexTs + ]); + + openFilesForSession([indexTs], session); + const autoImportProvider = projectService.configuredProjects.get(tsconfig.path)!.getLanguageService().getAutoImportProvider(); + assert.ok(autoImportProvider); + + updateFile(indexTs.path, "console.log(0)"); + assert.strictEqual( + projectService.configuredProjects.get(tsconfig.path)!.getLanguageService().getAutoImportProvider(), + autoImportProvider); + }); + + it("Closes AutoImportProviderProject when host project closes", () => { + const { projectService, session } = setup([ + angularFormsDts, + angularFormsPackageJson, + tsconfig, + packageJson, + indexTs + ]); + + openFilesForSession([indexTs], session); + const hostProject = projectService.configuredProjects.get(tsconfig.path)!; + hostProject.getPackageJsonAutoImportProvider(); + const autoImportProviderProject = hostProject.autoImportProviderHost; + assert.ok(autoImportProviderProject); + + hostProject.close(); + assert.ok(autoImportProviderProject && autoImportProviderProject.isClosed()); + assert.isUndefined(hostProject.autoImportProviderHost); + }); + + it("Does not schedule ensureProjectForOpenFiles on AutoImportProviderProject creation", () => { + const { projectService, session, host } = setup([ + angularFormsDts, + angularFormsPackageJson, + tsconfig, + indexTs + ]); + + // Create configured project only, ensure !projectService.pendingEnsureProjectForOpenFiles + openFilesForSession([indexTs], session); + const hostProject = projectService.configuredProjects.get(tsconfig.path)!; + projectService.delayEnsureProjectForOpenFiles(); + host.runQueuedTimeoutCallbacks(); + assert.isFalse(projectService.pendingEnsureProjectForOpenFiles); + + // Create auto import provider project, ensure still !projectService.pendingEnsureProjectForOpenFiles + host.writeFile(packageJson.path, packageJson.content); + hostProject.getPackageJsonAutoImportProvider(); + assert.isFalse(projectService.pendingEnsureProjectForOpenFiles); + }); + + it("Responds to automatic changes in node_modules", () => { + const { projectService, session, host } = setup([ + angularFormsDts, + angularFormsPackageJson, + angularCoreDts, + angularCorePackageJson, + tsconfig, + packageJson, + indexTs + ]); + + openFilesForSession([indexTs], session); + const project = projectService.configuredProjects.get(tsconfig.path)!; + const completionsBefore = project.getLanguageService().getCompletionsAtPosition(indexTs.path, 0, { includeCompletionsForModuleExports: true }); + assert.isTrue(completionsBefore?.entries.some(c => c.name === "PatternValidator")); + + // Directory watchers only fire for add/remove, not change. + // This is ok since a real `npm install` will always trigger add/remove events. + host.deleteFile(angularFormsDts.path); + host.writeFile(angularFormsDts.path, ""); + + const autoImportProvider = project.getLanguageService().getAutoImportProvider(); + const completionsAfter = project.getLanguageService().getCompletionsAtPosition(indexTs.path, 0, { includeCompletionsForModuleExports: true }); + assert.equal(autoImportProvider!.getSourceFile(angularFormsDts.path)!.getText(), ""); + assert.isFalse(completionsAfter?.entries.some(c => c.name === "PatternValidator")); + }); + + it("Responds to manual changes in node_modules", () => { + const { projectService, session, updateFile } = setup([ + angularFormsDts, + angularFormsPackageJson, + angularCoreDts, + angularCorePackageJson, + tsconfig, + packageJson, + indexTs + ]); + + openFilesForSession([indexTs, angularFormsDts], session); + const project = projectService.configuredProjects.get(tsconfig.path)!; + const completionsBefore = project.getLanguageService().getCompletionsAtPosition(indexTs.path, 0, { includeCompletionsForModuleExports: true }); + assert.isTrue(completionsBefore?.entries.some(c => c.name === "PatternValidator")); + + updateFile(angularFormsDts.path, "export class ValidatorPattern {}"); + const completionsAfter = project.getLanguageService().getCompletionsAtPosition(indexTs.path, 0, { includeCompletionsForModuleExports: true }); + assert.isFalse(completionsAfter?.entries.some(c => c.name === "PatternValidator")); + assert.isTrue(completionsAfter?.entries.some(c => c.name === "ValidatorPattern")); + }); + + it("Recovers from an unparseable package.json", () => { + const { projectService, session, host } = setup([ + angularFormsDts, + angularFormsPackageJson, + tsconfig, + { path: packageJson.path, content: "{" }, + indexTs + ]); + + openFilesForSession([indexTs], session); + assert.isUndefined(projectService.configuredProjects.get(tsconfig.path)!.getLanguageService().getAutoImportProvider()); + + host.writeFile(packageJson.path, packageJson.content); + assert.ok(projectService.configuredProjects.get(tsconfig.path)!.getLanguageService().getAutoImportProvider()); + }); + }); + + describe("unittests:: tsserver:: autoImportProvider - monorepo", () => { + it("Does not create auto import providers upon opening projects for find-all-references", () => { + const files = [ + // node_modules + angularFormsDts, + angularFormsPackageJson, + + // root + { path: tsconfig.path, content: `{ "references": [{ "path": "packages/a" }, { "path": "packages/b" }] }` }, + { path: packageJson.path, content: `{ "private": true }` }, + + // packages/a + { path: "/packages/a/package.json", content: packageJson.content }, + { path: "/packages/a/tsconfig.json", content: `{ "compilerOptions": { "composite": true }, "references": [{ "path": "../b" }] }` }, + { path: "/packages/a/index.ts", content: "import { B } from '../b';" }, + + // packages/b + { path: "/packages/b/package.json", content: packageJson.content }, + { path: "/packages/b/tsconfig.json", content: `{ "compilerOptions": { "composite": true } }` }, + { path: "/packages/b/index.ts", content: `export class B {}` } + ]; + + const { projectService, session, findAllReferences } = setup(files); + + openFilesForSession([files.find(f => f.path === "/packages/b/index.ts")!], session); + checkNumberOfConfiguredProjects(projectService, 2); // Solution (no files), B + findAllReferences("/packages/b/index.ts", 1, "export class B".length - 1); + checkNumberOfConfiguredProjects(projectService, 3); // Solution (no files), A, B + + // Project for A is created - ensure it doesn't have an autoImportProvider + assert.isUndefined(projectService.configuredProjects.get("/packages/a/tsconfig.json")!.getLanguageService().getAutoImportProvider()); + }); + }); + + function setup(files: File[]) { + const host = createServerHost(files); + const session = createSession(host); + const projectService = session.getProjectService(); + return { + host, + projectService, + session, + updateFile, + findAllReferences + }; + + function updateFile(path: string, newText: string) { + Debug.assertDefined(files.find(f => f.path === path)); + session.executeCommandSeq({ + command: protocol.CommandTypes.ApplyChangedToOpenFiles, + arguments: { + openFiles: [{ + fileName: path, + content: newText + }] + } + }); + } + + function findAllReferences(file: string, line: number, offset: number) { + Debug.assertDefined(files.find(f => f.path === file)); + session.executeCommandSeq({ + command: protocol.CommandTypes.References, + arguments: { + file, + line, + offset + } + }); + } + } +} diff --git a/src/testRunner/unittests/tsserver/cachingFileSystemInformation.ts b/src/testRunner/unittests/tsserver/cachingFileSystemInformation.ts index ed15145cf9cce..8fb177503864d 100644 --- a/src/testRunner/unittests/tsserver/cachingFileSystemInformation.ts +++ b/src/testRunner/unittests/tsserver/cachingFileSystemInformation.ts @@ -544,6 +544,7 @@ namespace ts.projectSystem { const otherFiles = [packageJson]; const host = createServerHost(projectFiles.concat(otherFiles)); const projectService = createProjectService(host); + projectService.setHostConfiguration({ preferences: { includePackageJsonAutoImports: "none" } }); const { configFileName } = projectService.openClientFile(app.path); assert.equal(configFileName, tsconfigJson.path as server.NormalizedPath, `should find config`); // TODO: GH#18217 const recursiveWatchedDirectories: string[] = [`${appFolder}`, `${appFolder}/node_modules`].concat(getNodeModuleDirectories(getDirectoryPath(appFolder))); diff --git a/src/testRunner/unittests/tsserver/completions.ts b/src/testRunner/unittests/tsserver/completions.ts index 857f1b6ebc1e0..919295bad8056 100644 --- a/src/testRunner/unittests/tsserver/completions.ts +++ b/src/testRunner/unittests/tsserver/completions.ts @@ -36,6 +36,7 @@ namespace ts.projectSystem { kindModifiers: ScriptElementKindModifier.exportedModifier, name: "foo", replacementSpan: undefined, + isPackageJsonImport: undefined, sortText: Completions.SortText.AutoImportSuggestions, source: "/a", }; diff --git a/src/testRunner/unittests/tsserver/importSuggestionsCache.ts b/src/testRunner/unittests/tsserver/importSuggestionsCache.ts index 053c230972402..982eece3d36ad 100644 --- a/src/testRunner/unittests/tsserver/importSuggestionsCache.ts +++ b/src/testRunner/unittests/tsserver/importSuggestionsCache.ts @@ -1,4 +1,8 @@ namespace ts.projectSystem { + const packageJson: File = { + path: "/package.json", + content: `{ "dependencies": { "mobx": "*" } }` + }; const aTs: File = { path: "/a.ts", content: "export const foo = 0;", @@ -15,6 +19,10 @@ namespace ts.projectSystem { 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", () => { @@ -36,10 +44,17 @@ namespace ts.projectSystem { 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]); + const host = createServerHost([aTs, bTs, ambientDeclaration, tsconfig, packageJson, mobxDts]); const session = createSession(host); openFilesForSession([aTs, bTs], session); const projectService = session.getProjectService(); diff --git a/src/testRunner/unittests/tsserver/packageJsonInfo.ts b/src/testRunner/unittests/tsserver/packageJsonInfo.ts index 4e0908ec9092a..efc977a335c92 100644 --- a/src/testRunner/unittests/tsserver/packageJsonInfo.ts +++ b/src/testRunner/unittests/tsserver/packageJsonInfo.ts @@ -25,12 +25,12 @@ namespace ts.projectSystem { describe("unittests:: tsserver:: packageJsonInfo", () => { it("detects new package.json files that are added, caches them, and watches them", () => { // Initialize project without package.json - const { project, host } = setup([tsConfig]); - assert.isUndefined(project.packageJsonCache.getInDirectory("/" as Path)); + const { projectService, host } = setup([tsConfig]); + assert.isUndefined(projectService.packageJsonCache.getInDirectory("/" as Path)); // Add package.json host.writeFile(packageJson.path, packageJson.content); - let packageJsonInfo = project.packageJsonCache.getInDirectory("/" as Path)!; + let packageJsonInfo = projectService.packageJsonCache.getInDirectory("/" as Path)!; assert.ok(packageJsonInfo); assert.ok(packageJsonInfo.dependencies); assert.ok(packageJsonInfo.devDependencies); @@ -42,40 +42,40 @@ namespace ts.projectSystem { ...packageJsonContent, dependencies: undefined })); - packageJsonInfo = project.packageJsonCache.getInDirectory("/" as Path)!; + packageJsonInfo = projectService.packageJsonCache.getInDirectory("/" as Path)!; assert.isUndefined(packageJsonInfo.dependencies); }); it("finds package.json on demand, watches for deletion, and removes them from cache", () => { // Initialize project with package.json - const { project, host } = setup(); - project.getPackageJsonsVisibleToFile("/src/whatever/blah.ts" as Path); - assert.ok(project.packageJsonCache.getInDirectory("/" as Path)); + const { projectService, host } = setup(); + projectService.getPackageJsonsVisibleToFile("/src/whatever/blah.ts" as Path); + assert.ok(projectService.packageJsonCache.getInDirectory("/" as Path)); // Delete package.json host.deleteFile(packageJson.path); - assert.isUndefined(project.packageJsonCache.getInDirectory("/" as Path)); + assert.isUndefined(projectService.packageJsonCache.getInDirectory("/" as Path)); }); it("finds multiple package.json files when present", () => { // Initialize project with package.json at root - const { project, host } = setup(); + const { projectService, host } = setup(); // Add package.json in /src host.writeFile("/src/package.json", packageJson.content); - assert.lengthOf(project.getPackageJsonsVisibleToFile("/a.ts" as Path), 1); - assert.lengthOf(project.getPackageJsonsVisibleToFile("/src/b.ts" as Path), 2); + assert.lengthOf(projectService.getPackageJsonsVisibleToFile("/a.ts" as Path), 1); + assert.lengthOf(projectService.getPackageJsonsVisibleToFile("/src/b.ts" as Path), 2); }); it("handles errors in json parsing of package.json", () => { const packageJsonContent = `{ "mod" }`; - const { project, host } = setup([tsConfig, { path: packageJson.path, content: packageJsonContent }]); - project.getPackageJsonsVisibleToFile("/src/whatever/blah.ts" as Path); - const packageJsonInfo = project.packageJsonCache.getInDirectory("/" as Path)!; - assert.isUndefined(packageJsonInfo); + const { projectService, host } = setup([tsConfig, { path: packageJson.path, content: packageJsonContent }]); + projectService.getPackageJsonsVisibleToFile("/src/whatever/blah.ts" as Path); + const packageJsonInfo = projectService.packageJsonCache.getInDirectory("/" as Path)!; + assert.isFalse(packageJsonInfo.parseable); host.writeFile(packageJson.path, packageJson.content); - project.getPackageJsonsVisibleToFile("/src/whatever/blah.ts" as Path); - const packageJsonInfo2 = project.packageJsonCache.getInDirectory("/" as Path)!; + projectService.getPackageJsonsVisibleToFile("/src/whatever/blah.ts" as Path); + const packageJsonInfo2 = projectService.packageJsonCache.getInDirectory("/" as Path)!; assert.ok(packageJsonInfo2); assert.ok(packageJsonInfo2.dependencies); assert.ok(packageJsonInfo2.devDependencies); diff --git a/src/testRunner/unittests/tsserver/semanticOperationsOnSyntaxServer.ts b/src/testRunner/unittests/tsserver/semanticOperationsOnSyntaxServer.ts index 89aea7609de0b..de2e2c9753f68 100644 --- a/src/testRunner/unittests/tsserver/semanticOperationsOnSyntaxServer.ts +++ b/src/testRunner/unittests/tsserver/semanticOperationsOnSyntaxServer.ts @@ -56,6 +56,7 @@ class c { prop = "hello"; foo() { return this.prop; } }` sortText: Completions.SortText.LocationPriority, hasAction: undefined, insertText: undefined, + isPackageJsonImport: undefined, isRecommended: undefined, replacementSpan: undefined, source: undefined diff --git a/src/testRunner/unittests/tsserver/typingsInstaller.ts b/src/testRunner/unittests/tsserver/typingsInstaller.ts index 2120e86836d27..699fb59b61968 100644 --- a/src/testRunner/unittests/tsserver/typingsInstaller.ts +++ b/src/testRunner/unittests/tsserver/typingsInstaller.ts @@ -130,6 +130,7 @@ namespace ts.projectSystem { })(); const projectService = createProjectService(host, { useSingleInferredProject: true, typingsInstaller: installer }); + projectService.setHostConfiguration({ preferences: { includePackageJsonAutoImports: "none" } }); projectService.openClientFile(file1.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 8aad8acd89ae5..92bc4d88f81b0 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -3789,6 +3789,7 @@ declare namespace ts { readonly importModuleSpecifierEnding?: "auto" | "minimal" | "index" | "js"; readonly allowTextChangesInNewFiles?: boolean; readonly providePrefixAndSuffixTextForRename?: boolean; + readonly includePackageJsonAutoImports?: "exclude-dev" | "all" | "none"; readonly provideRefactorNotApplicableReason?: boolean; } /** Represents a bigint literal value without requiring bigint support */ @@ -5279,6 +5280,10 @@ declare namespace ts { fileName: Path; packageName: string; } + interface PerformanceEvent { + kind: "UpdateGraph" | "CreatePackageJsonAutoImportProvider"; + durationMs: number; + } interface LanguageServiceHost extends GetEffectiveTypeRootsHost { getCompilationSettings(): CompilerOptions; getNewLine?(): string; @@ -5943,6 +5948,7 @@ declare namespace ts { source?: string; isRecommended?: true; isFromUncheckedFile?: true; + isPackageJsonImport?: true; } interface CompletionEntryDetails { name: string; @@ -6539,6 +6545,10 @@ declare namespace ts.server.protocol { * Time spent updating the program graph, in milliseconds. */ updateGraphDurationMs?: number; + /** + * The time spent creating or updating the auto-import program, in milliseconds. + */ + createAutoImportProviderProgramDurationMs?: number; } /** * Arguments for FileRequest messages. @@ -7980,6 +7990,11 @@ declare namespace ts.server.protocol { * and therefore may not be accurate. */ isFromUncheckedFile?: true; + /** + * If true, this completion was for an auto-import of a module not yet in the program, but listed + * in the project package.json. + */ + isPackageJsonImport?: true; } /** * Additional completion entry details, available on demand @@ -8828,6 +8843,7 @@ declare namespace ts.server.protocol { readonly lazyConfiguredProjectsFromExternalProject?: boolean; readonly providePrefixAndSuffixTextForRename?: boolean; readonly allowRenameOfImportPath?: boolean; + readonly includePackageJsonAutoImports?: "exclude-dev" | "all" | "none"; } interface CompilerOptions { allowJs?: boolean; @@ -9010,7 +9026,8 @@ declare namespace ts.server { enum ProjectKind { Inferred = 0, Configured = 1, - External = 2 + External = 2, + AutoImportProvider = 3 } function allRootFilesAreJsOrDts(project: Project): boolean; function allFilesAreJsOrDts(project: Project): boolean; @@ -9171,7 +9188,6 @@ declare namespace ts.server { private enableProxy; /** Starts a new check for diagnostics. Call this if some file has updated that would cause diagnostics to be changed. */ refreshDiagnostics(): void; - private watchPackageJsonFile; } /** * If a file is opened and no tsconfig (or jsconfig) is found, @@ -9190,6 +9206,18 @@ declare namespace ts.server { close(): void; getTypeAcquisition(): TypeAcquisition; } + class AutoImportProviderProject extends Project { + private hostProject; + private static readonly newName; + private rootFileNames; + isOrphan(): boolean; + updateGraph(): boolean; + markAsDirty(): void; + getScriptFileNames(): string[]; + getLanguageService(): never; + markAutoImportProviderAsDirty(): never; + getTypeAcquisition(): TypeAcquisition; + } /** * If a file is opened, the server will look for a tsconfig (or jsconfig) * and if successful create a ConfiguredProject for it. @@ -9700,7 +9728,7 @@ declare namespace ts.server { private readonly gcTimer; protected projectService: ProjectService; private changeSeq; - private updateGraphDurationMs; + private performanceData; private currentRequestId; private errorCheck; protected host: ServerHost; @@ -9715,6 +9743,7 @@ declare namespace ts.server { private readonly noGetErrOnBackgroundUpdate?; constructor(opts: SessionOptions); private sendRequestCompletedEvent; + private addPerformanceData; private performanceEventHandler; private defaultEventHandler; private projectsUpdatedInBackgroundEvent; diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index df02cd057f129..8a99cf57478f2 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -3789,6 +3789,7 @@ declare namespace ts { readonly importModuleSpecifierEnding?: "auto" | "minimal" | "index" | "js"; readonly allowTextChangesInNewFiles?: boolean; readonly providePrefixAndSuffixTextForRename?: boolean; + readonly includePackageJsonAutoImports?: "exclude-dev" | "all" | "none"; readonly provideRefactorNotApplicableReason?: boolean; } /** Represents a bigint literal value without requiring bigint support */ @@ -5279,6 +5280,10 @@ declare namespace ts { fileName: Path; packageName: string; } + interface PerformanceEvent { + kind: "UpdateGraph" | "CreatePackageJsonAutoImportProvider"; + durationMs: number; + } interface LanguageServiceHost extends GetEffectiveTypeRootsHost { getCompilationSettings(): CompilerOptions; getNewLine?(): string; @@ -5943,6 +5948,7 @@ declare namespace ts { source?: string; isRecommended?: true; isFromUncheckedFile?: true; + isPackageJsonImport?: true; } interface CompletionEntryDetails { name: string; diff --git a/tests/cases/fourslash/completionsImport_filteredByPackageJson_peerDependencies.ts b/tests/cases/fourslash/completionsImport_filteredByPackageJson_peerDependencies.ts new file mode 100644 index 0000000000000..1acf2e197bb7b --- /dev/null +++ b/tests/cases/fourslash/completionsImport_filteredByPackageJson_peerDependencies.ts @@ -0,0 +1,46 @@ +/// + +//@noEmit: true + +//@Filename: /package.json +////{ +//// "peerDependencies": { +//// "react": "*" +//// } +////} + +//@Filename: /node_modules/react/index.d.ts +////export declare var React: any; + +//@Filename: /node_modules/react/package.json +////{ +//// "name": "react", +//// "types": "./index.d.ts" +////} + +//@Filename: /node_modules/fake-react/index.d.ts +////export declare var ReactFake: any; + +//@Filename: /node_modules/fake-react/package.json +////{ +//// "name": "fake-react", +//// "types": "./index.d.ts" +////} + +//@Filename: /src/index.ts +////const x = Re/**/ + +verify.completions({ + marker: test.marker(""), + isNewIdentifierLocation: true, + includes: { + name: "React", + hasAction: true, + source: "/node_modules/react/index", + sortText: completion.SortText.AutoImportSuggestions + }, + excludes: "ReactFake", + preferences: { + includeCompletionsForModuleExports: true + } +}); diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index 343a3fd5e734b..3efb65d1251c7 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -621,6 +621,7 @@ declare namespace FourSlashInterface { readonly kind?: string; readonly kindModifiers?: string; readonly sortText?: completion.SortText; + readonly isPackageJsonImport?: boolean; // details readonly text?: string; diff --git a/tests/cases/fourslash/server/autoImportProvider1.ts b/tests/cases/fourslash/server/autoImportProvider1.ts new file mode 100644 index 0000000000000..d2eaf16c392de --- /dev/null +++ b/tests/cases/fourslash/server/autoImportProvider1.ts @@ -0,0 +1,24 @@ +/// + +// @Filename: /node_modules/@angular/forms/package.json +//// { "name": "@angular/forms", "typings": "./forms.d.ts" } + +// @Filename: /node_modules/@angular/forms/forms.d.ts +//// export class PatternValidator {} + +// @Filename: /tsconfig.json +//// {} + +// @Filename: /package.json +//// { "dependencies": { "@angular/forms": "*" } } + +// @Filename: /index.ts +//// PatternValidator/**/ + +goTo.marker(""); +format.setOption("newLineCharacter", "\n"); +verify.importFixAtPosition([ +`import { PatternValidator } from "@angular/forms"; + +PatternValidator` +]); diff --git a/tests/cases/fourslash/server/autoImportProvider2.ts b/tests/cases/fourslash/server/autoImportProvider2.ts new file mode 100644 index 0000000000000..2417431d98c91 --- /dev/null +++ b/tests/cases/fourslash/server/autoImportProvider2.ts @@ -0,0 +1,33 @@ +/// + +// @Filename: /node_modules/direct-dependency/package.json +//// { "name": "direct-dependency", "dependencies": { "indirect-dependency": "*" } } + +// @Filename: /node_modules/direct-dependency/index.d.ts +//// import "indirect-dependency"; +//// export declare class DirectDependency {} + +// @Filename: /node_modules/indirect-dependency/package.json +//// { "name": "indirect-dependency" } + +// @Filename: /node_modules/indirect-dependency/index.d.ts +//// export declare class IndirectDependency + +// @Filename: /tsconfig.json +//// {} + +// @Filename: /package.json +//// { "dependencies": { "direct-dependency": "*" } } + +// @Filename: /index.ts +//// IndirectDependency/**/ + + + +// `IndirectDependency` is in the autoImportProvider program, but +// filtered out of the suggestions because it is not a direct +// dependency of the project. + +goTo.marker(""); +format.setOption("newLineCharacter", "\n"); +verify.importFixAtPosition([]); diff --git a/tests/cases/fourslash/server/autoImportProvider3.ts b/tests/cases/fourslash/server/autoImportProvider3.ts new file mode 100644 index 0000000000000..72d63141bd023 --- /dev/null +++ b/tests/cases/fourslash/server/autoImportProvider3.ts @@ -0,0 +1,48 @@ +/// + +// @Filename: /node_modules/common-dependency/package.json +//// { "name": "common-dependency" } + +// @Filename: /node_modules/common-dependency/index.d.ts +//// export declare class CommonDependency {} + +// @Filename: /node_modules/package-dependency/package.json +//// { "name": "package-dependency" } + +// @Filename: /node_modules/package-dependency/index.d.ts +//// export declare class PackageDependency + +// @Filename: /package.json +//// { "private": true, "dependencies": { "common-dependency": "*" } } + +// @Filename: /tsconfig.json +//// { "files": [], "references": [{ "path": "packages/a" }] } + +// @Filename: /packages/a/tsconfig.json +//// { "compilerOptions": { "target": "esnext", "composite": true } } + +// @Filename: /packages/a/package.json +//// { "peerDependencies": { "package-dependency": "*" } } + +// @Filename: /packages/a/index.ts +//// /**/ + +goTo.marker(""); +verify.completions({ + includes: [{ + name: "PackageDependency", + hasAction: true, + source: "/node_modules/package-dependency/index", + sortText: completion.SortText.AutoImportSuggestions, + isPackageJsonImport: true + }, { + name: "CommonDependency", + hasAction: true, + source: "/node_modules/common-dependency/index", + sortText: completion.SortText.AutoImportSuggestions, + isPackageJsonImport: true + }], + preferences: { + includeCompletionsForModuleExports: true + } +});