diff --git a/src/compiler/core.ts b/src/compiler/core.ts index 395ddb4057434..d8f8cff42a259 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -456,6 +456,44 @@ namespace ts { return result; } + export function arrayIsEqualTo(array1: ReadonlyArray, array2: ReadonlyArray, equaler?: (a: T, b: T) => boolean): boolean { + if (!array1 || !array2) { + return array1 === array2; + } + + if (array1.length !== array2.length) { + return false; + } + + for (let i = 0; i < array1.length; i++) { + const equals = equaler ? equaler(array1[i], array2[i]) : array1[i] === array2[i]; + if (!equals) { + return false; + } + } + + return true; + } + + export function changesAffectModuleResolution(oldOptions: CompilerOptions, newOptions: CompilerOptions): boolean { + return !oldOptions || + (oldOptions.module !== newOptions.module) || + (oldOptions.moduleResolution !== newOptions.moduleResolution) || + (oldOptions.noResolve !== newOptions.noResolve) || + (oldOptions.target !== newOptions.target) || + (oldOptions.noLib !== newOptions.noLib) || + (oldOptions.jsx !== newOptions.jsx) || + (oldOptions.allowJs !== newOptions.allowJs) || + (oldOptions.rootDir !== newOptions.rootDir) || + (oldOptions.configFilePath !== newOptions.configFilePath) || + (oldOptions.baseUrl !== newOptions.baseUrl) || + (oldOptions.maxNodeModuleJsDepth !== newOptions.maxNodeModuleJsDepth) || + !arrayIsEqualTo(oldOptions.lib, newOptions.lib) || + !arrayIsEqualTo(oldOptions.typeRoots, newOptions.typeRoots) || + !arrayIsEqualTo(oldOptions.rootDirs, newOptions.rootDirs) || + !equalOwnProperties(oldOptions.paths, newOptions.paths); + } + /** * Compacts an array, removing any falsey elements. */ @@ -821,7 +859,7 @@ namespace ts { return result; } - export function extend(first: T1 , second: T2): T1 & T2 { + export function extend(first: T1, second: T2): T1 & T2 { const result: T1 & T2 = {}; for (const id in second) if (hasOwnProperty.call(second, id)) { (result as any)[id] = (second as any)[id]; @@ -974,8 +1012,8 @@ namespace ts { Debug.assert(length >= 0, "length must be non-negative, is " + length); if (file) { - Debug.assert(start <= file.text.length, `start must be within the bounds of the file. ${ start } > ${ file.text.length }`); - Debug.assert(end <= file.text.length, `end must be the bounds of the file. ${ end } > ${ file.text.length }`); + Debug.assert(start <= file.text.length, `start must be within the bounds of the file. ${start} > ${file.text.length}`); + Debug.assert(end <= file.text.length, `end must be the bounds of the file. ${end} > ${file.text.length}`); } let text = getLocaleSpecificMessage(message); @@ -1525,7 +1563,7 @@ namespace ts { return undefined; } - const replaceWildcardCharacter = usage === "files" ? replaceWildCardCharacterFiles : replaceWildCardCharacterOther; + const replaceWildcardCharacter = usage === "files" ? replaceWildCardCharacterFiles : replaceWildCardCharacterOther; const singleAsteriskRegexFragment = usage === "files" ? singleAsteriskRegexFragmentFiles : singleAsteriskRegexFragmentOther; /** @@ -1767,7 +1805,7 @@ namespace ts { /** Must have ".d.ts" first because if ".ts" goes first, that will be detected as the extension instead of ".d.ts". */ export const supportedTypescriptExtensionsForExtractExtension = [".d.ts", ".ts", ".tsx"]; export const supportedJavascriptExtensions = [".js", ".jsx"]; - const allSupportedExtensions = supportedTypeScriptExtensions.concat(supportedJavascriptExtensions); + const allSupportedExtensions = supportedTypeScriptExtensions.concat(supportedJavascriptExtensions); export function getSupportedExtensions(options?: CompilerOptions): string[] { return options && options.allowJs ? allSupportedExtensions : supportedTypeScriptExtensions; diff --git a/src/compiler/program.ts b/src/compiler/program.ts index a9c64f3ecbe3a..25e85d13dc9a8 100644 --- a/src/compiler/program.ts +++ b/src/compiler/program.ts @@ -462,21 +462,7 @@ namespace ts { // check properties that can affect structure of the program or module resolution strategy // if any of these properties has changed - structure cannot be reused const oldOptions = oldProgram.getCompilerOptions(); - if ((oldOptions.module !== options.module) || - (oldOptions.moduleResolution !== options.moduleResolution) || - (oldOptions.noResolve !== options.noResolve) || - (oldOptions.target !== options.target) || - (oldOptions.noLib !== options.noLib) || - (oldOptions.jsx !== options.jsx) || - (oldOptions.allowJs !== options.allowJs) || - (oldOptions.rootDir !== options.rootDir) || - (oldOptions.configFilePath !== options.configFilePath) || - (oldOptions.baseUrl !== options.baseUrl) || - (oldOptions.maxNodeModuleJsDepth !== options.maxNodeModuleJsDepth) || - !arrayIsEqualTo(oldOptions.lib, options.lib) || - !arrayIsEqualTo(oldOptions.typeRoots, options.typeRoots) || - !arrayIsEqualTo(oldOptions.rootDirs, options.rootDirs) || - !equalOwnProperties(oldOptions.paths, options.paths)) { + if (changesAffectModuleResolution(oldOptions, options)) { return false; } diff --git a/src/compiler/types.ts b/src/compiler/types.ts index f9ec3e9f268cc..11def32d85816 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -3034,6 +3034,7 @@ namespace ts { packageNameToTypingLocation: Map; // The map of package names to their cached typing locations typingOptions: TypingOptions; // Used to customize the typing inference process compilerOptions: CompilerOptions; // Used as a source for typing inference + unresolvedImports: ReadonlyArray; // List of unresolved module ids from imports } export enum ModuleKind { diff --git a/src/compiler/utilities.ts b/src/compiler/utilities.ts index 3a8d09f113da8..d4744dd0ae9f9 100644 --- a/src/compiler/utilities.ts +++ b/src/compiler/utilities.ts @@ -83,25 +83,6 @@ namespace ts { return node.end - node.pos; } - export function arrayIsEqualTo(array1: ReadonlyArray, array2: ReadonlyArray, equaler?: (a: T, b: T) => boolean): boolean { - if (!array1 || !array2) { - return array1 === array2; - } - - if (array1.length !== array2.length) { - return false; - } - - for (let i = 0; i < array1.length; i++) { - const equals = equaler ? equaler(array1[i], array2[i]) : array1[i] === array2[i]; - if (!equals) { - return false; - } - } - - return true; - } - export function hasResolvedModule(sourceFile: SourceFile, moduleNameText: string): boolean { return !!(sourceFile && sourceFile.resolvedModules && sourceFile.resolvedModules[moduleNameText]); } diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index 2abfd66b98344..7f12036a77740 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -94,8 +94,8 @@ namespace ts.projectSystem { this.projectService.updateTypingsForProject(response); } - enqueueInstallTypingsRequest(project: server.Project, typingOptions: TypingOptions) { - const request = server.createInstallTypingsRequest(project, typingOptions, this.globalTypingsCacheLocation); + enqueueInstallTypingsRequest(project: server.Project, typingOptions: TypingOptions, unresolvedImports: server.SortedReadonlyArray) { + const request = server.createInstallTypingsRequest(project, typingOptions, unresolvedImports, this.globalTypingsCacheLocation); this.install(request); } diff --git a/src/harness/unittests/typingsInstaller.ts b/src/harness/unittests/typingsInstaller.ts index 4a0021e6ad43d..356992d57176e 100644 --- a/src/harness/unittests/typingsInstaller.ts +++ b/src/harness/unittests/typingsInstaller.ts @@ -217,9 +217,9 @@ namespace ts.projectSystem { constructor() { super(host); } - enqueueInstallTypingsRequest(project: server.Project, typingOptions: TypingOptions) { + enqueueInstallTypingsRequest(project: server.Project, typingOptions: TypingOptions, unresolvedImports: server.SortedReadonlyArray) { enqueueIsCalled = true; - super.enqueueInstallTypingsRequest(project, typingOptions); + super.enqueueInstallTypingsRequest(project, typingOptions, unresolvedImports); } executeRequest(requestKind: TI.RequestKind, _requestId: number, _args: string[], _cwd: string, cb: TI.RequestCompletedAction): void { const installedTypings = ["@types/jquery"]; @@ -319,9 +319,9 @@ namespace ts.projectSystem { constructor() { super(host); } - enqueueInstallTypingsRequest(project: server.Project, typingOptions: TypingOptions) { + enqueueInstallTypingsRequest(project: server.Project, typingOptions: TypingOptions, unresolvedImports: server.SortedReadonlyArray) { enqueueIsCalled = true; - super.enqueueInstallTypingsRequest(project, typingOptions); + super.enqueueInstallTypingsRequest(project, typingOptions, unresolvedImports); } executeRequest(requestKind: TI.RequestKind, _requestId: number, _args: string[], _cwd: string, cb: TI.RequestCompletedAction): void { const installedTypings: string[] = []; @@ -724,7 +724,7 @@ namespace ts.projectSystem { it("Malformed package.json should be watched", () => { const f = { path: "/a/b/app.js", - content: "var x = require('commander')" + content: "var x = 1" }; const brokenPackageJson = { path: "/a/b/package.json", @@ -763,6 +763,133 @@ namespace ts.projectSystem { service.checkNumberOfProjects({ inferredProjects: 1 }); checkProjectActualFiles(service.inferredProjects[0], [f.path, commander.path]); }); + + it("should install typings for unresolved imports", () => { + const file = { + path: "/a/b/app.js", + content: ` + import * as fs from "fs"; + import * as commander from "commander";` + }; + const cachePath = "/a/cache"; + const node = { + path: cachePath + "/node_modules/@types/node/index.d.ts", + content: "export let x: number" + }; + const commander = { + path: cachePath + "/node_modules/@types/commander/index.d.ts", + content: "export let y: string" + }; + const host = createServerHost([file]); + const installer = new (class extends Installer { + constructor() { + super(host, { globalTypingsCacheLocation: cachePath }); + } + executeRequest(requestKind: TI.RequestKind, _requestId: number, _args: string[], _cwd: string, cb: server.typingsInstaller.RequestCompletedAction) { + const installedTypings = ["@types/node", "@types/commander"]; + const typingFiles = [node, commander]; + executeCommand(this, host, installedTypings, typingFiles, requestKind, cb); + } + })(); + const service = createProjectService(host, { typingsInstaller: installer }); + service.openClientFile(file.path); + + service.checkNumberOfProjects({ inferredProjects: 1 }); + checkProjectActualFiles(service.inferredProjects[0], [file.path]); + + installer.installAll([TI.NpmViewRequest, TI.NpmViewRequest], [TI.NpmInstallRequest]); + + assert.isTrue(host.fileExists(node.path), "typings for 'node' should be created"); + assert.isTrue(host.fileExists(commander.path), "typings for 'commander' should be created"); + + checkProjectActualFiles(service.inferredProjects[0], [file.path, node.path, commander.path]); + }); + + it("should pick typing names from non-relative unresolved imports", () => { + const f1 = { + path: "/a/b/app.js", + content: ` + import * as a from "foo/a/a"; + import * as b from "foo/a/b"; + import * as c from "foo/a/c"; + import * as d from "@bar/router/"; + import * as e from "@bar/common/shared"; + import * as e from "@bar/common/apps"; + import * as f from "./lib" + ` + }; + + const host = createServerHost([f1]); + const installer = new (class extends Installer { + constructor() { + super(host, { globalTypingsCacheLocation: "/tmp" }); + } + executeRequest(requestKind: TI.RequestKind, _requestId: number, args: string[], _cwd: string, cb: server.typingsInstaller.RequestCompletedAction) { + if (requestKind === TI.NpmViewRequest) { + // args should have only non-scoped packages - scoped packages are not yet supported + assert.deepEqual(args, ["foo"]); + } + executeCommand(this, host, ["foo"], [], requestKind, cb); + } + })(); + const projectService = createProjectService(host, { typingsInstaller: installer }); + projectService.openClientFile(f1.path); + projectService.checkNumberOfProjects({ inferredProjects: 1 }); + + const proj = projectService.inferredProjects[0]; + proj.updateGraph(); + + assert.deepEqual( + proj.getCachedUnresolvedImportsPerFile_TestOnly().get(f1.path), + ["foo", "foo", "foo", "@bar/router", "@bar/common", "@bar/common"] + ); + + installer.installAll([TI.NpmViewRequest], [TI.NpmInstallRequest]); + }); + + it("cached unresolved typings are not recomputed if program structure did not change", () => { + const host = createServerHost([]); + const session = createSession(host); + const f = { + path: "/a/app.js", + content: ` + import * as fs from "fs"; + import * as cmd from "commander + ` + }; + session.executeCommand({ + seq: 1, + type: "request", + command: "open", + arguments: { + file: f.path, + fileContent: f.content + } + }); + const projectService = session.getProjectService(); + checkNumberOfProjects(projectService, { inferredProjects: 1 }); + const proj = projectService.inferredProjects[0]; + const version1 = proj.getCachedUnresolvedImportsPerFile_TestOnly().getVersion(); + + // make a change that should not affect the structure of the program + session.executeCommand({ + seq: 2, + type: "request", + command: "change", + arguments: { + file: f.path, + insertString: "\nlet x = 1;", + line: 2, + offset: 0, + endLine: 2, + endOffset: 0 + } + }); + host.checkTimeoutQueueLength(1); + host.runQueuedTimeoutCallbacks(); + const version2 = proj.getCachedUnresolvedImportsPerFile_TestOnly().getVersion(); + assert.equal(version1, version2, "set of unresolved imports should not change"); + }); }); describe("Validate package name:", () => { @@ -820,4 +947,35 @@ namespace ts.projectSystem { assert.isTrue(messages.indexOf("Package name '; say ‘Hello from TypeScript!’ #' contains non URI safe characters") > 0, "should find package with invalid name"); }); }); + + describe("discover typings", () => { + it("should return node for core modules", () => { + const f = { + path: "/a/b/app.js", + content: "" + }; + const host = createServerHost([f]); + const cache = createMap(); + for (const name of JsTyping.nodeCoreModuleList) { + const result = JsTyping.discoverTypings(host, [f.path], getDirectoryPath(f.path), /*safeListPath*/ undefined, cache, { enableAutoDiscovery: true }, [name, "somename"]); + assert.deepEqual(result.newTypingNames.sort(), ["node", "somename"]); + } + }); + + it("should use cached locaitons", () => { + const f = { + path: "/a/b/app.js", + content: "" + }; + const node = { + path: "/a/b/node.d.ts", + content: "" + }; + const host = createServerHost([f, node]); + const cache = createMap({ "node": node.path }); + const result = JsTyping.discoverTypings(host, [f.path], getDirectoryPath(f.path), /*safeListPath*/ undefined, cache, { enableAutoDiscovery: true }, ["fs", "bar"]); + assert.deepEqual(result.cachedTypingPaths, [node.path]); + assert.deepEqual(result.newTypingNames, ["bar"]); + }); + }); } \ No newline at end of file diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 6556aa0146046..6565940c22d08 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -287,13 +287,13 @@ namespace ts.server { } switch (response.kind) { case "set": - this.typingsCache.updateTypingsForProject(response.projectName, response.compilerOptions, response.typingOptions, response.typings); - project.updateGraph(); + this.typingsCache.updateTypingsForProject(response.projectName, response.compilerOptions, response.typingOptions, response.unresolvedImports, response.typings); break; case "invalidate": - this.typingsCache.invalidateCachedTypingsForProject(project); + this.typingsCache.deleteTypingsForProject(response.projectName); break; } + project.updateGraph(); } setCompilerOptionsForInferredProjects(projectCompilerOptions: protocol.ExternalProjectCompilerOptions): void { diff --git a/src/server/lsHost.ts b/src/server/lsHost.ts index b36f73a19c545..8b882197820cf 100644 --- a/src/server/lsHost.ts +++ b/src/server/lsHost.ts @@ -9,6 +9,8 @@ namespace ts.server { private readonly resolvedTypeReferenceDirectives: ts.FileMap>; private readonly getCanonicalFileName: (fileName: string) => string; + private filesWithChangedSetOfUnresolvedImports: Path[]; + private readonly resolveModuleName: typeof resolveModuleName; readonly trace: (s: string) => void; @@ -52,12 +54,23 @@ namespace ts.server { }; } + public startRecordingFilesWithChangedResolutions() { + this.filesWithChangedSetOfUnresolvedImports = []; + } + + public finishRecordingFilesWithChangedResolutions() { + const collected = this.filesWithChangedSetOfUnresolvedImports; + this.filesWithChangedSetOfUnresolvedImports = undefined; + return collected; + } + private resolveNamesWithLocalCache( names: string[], containingFile: string, cache: ts.FileMap>, loader: (name: string, containingFile: string, options: CompilerOptions, host: ModuleResolutionHost) => T, - getResult: (s: T) => R): R[] { + getResult: (s: T) => R, + logChanges: boolean): R[] { const path = toPath(containingFile, this.host.getCurrentDirectory(), this.getCanonicalFileName); const currentResolutionsInFile = cache.get(path); @@ -79,6 +92,11 @@ namespace ts.server { else { newResolutions[name] = resolution = loader(name, containingFile, compilerOptions, this); } + if (logChanges && this.filesWithChangedSetOfUnresolvedImports && !resolutionIsEqualTo(existingResolution, resolution)) { + this.filesWithChangedSetOfUnresolvedImports.push(path); + // reset log changes to avoid recording the same file multiple times + logChanges = false; + } } ts.Debug.assert(resolution !== undefined); @@ -90,6 +108,24 @@ namespace ts.server { cache.set(path, newResolutions); return resolvedModules; + function resolutionIsEqualTo(oldResolution: T, newResolution: T): boolean { + if (oldResolution === newResolution) { + return true; + } + if (!oldResolution || !newResolution) { + return false; + } + const oldResult = getResult(oldResolution); + const newResult = getResult(newResolution); + if (oldResult === newResult) { + return true; + } + if (!oldResult || !newResult) { + return false; + } + return oldResult.resolvedFileName === newResult.resolvedFileName; + } + function moduleResolutionIsValid(resolution: T): boolean { if (!resolution) { return false; @@ -126,11 +162,11 @@ namespace ts.server { } resolveTypeReferenceDirectives(typeDirectiveNames: string[], containingFile: string): ResolvedTypeReferenceDirective[] { - return this.resolveNamesWithLocalCache(typeDirectiveNames, containingFile, this.resolvedTypeReferenceDirectives, resolveTypeReferenceDirective, m => m.resolvedTypeReferenceDirective); + return this.resolveNamesWithLocalCache(typeDirectiveNames, containingFile, this.resolvedTypeReferenceDirectives, resolveTypeReferenceDirective, m => m.resolvedTypeReferenceDirective, /*logChanges*/ false); } resolveModuleNames(moduleNames: string[], containingFile: string): ResolvedModule[] { - return this.resolveNamesWithLocalCache(moduleNames, containingFile, this.resolvedModuleNames, this.resolveModuleName, m => m.resolvedModule); + return this.resolveNamesWithLocalCache(moduleNames, containingFile, this.resolvedModuleNames, this.resolveModuleName, m => m.resolvedModule, /*logChanges*/ true); } getDefaultLibFileName() { @@ -197,10 +233,11 @@ namespace ts.server { } setCompilationSettings(opt: ts.CompilerOptions) { + if (changesAffectModuleResolution(this.compilationSettings, opt)) { + this.resolvedModuleNames.clear(); + this.resolvedTypeReferenceDirectives.clear(); + } this.compilationSettings = opt; - // conservatively assume that changing compiler options might affect module resolution strategy - this.resolvedModuleNames.clear(); - this.resolvedTypeReferenceDirectives.clear(); } } } \ No newline at end of file diff --git a/src/server/project.ts b/src/server/project.ts index dd8902e83d1e3..5f4f8d4049027 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -62,12 +62,43 @@ namespace ts.server { projectErrors: Diagnostic[]; } + export class UnresolvedImportsMap { + readonly perFileMap = createFileMap>(); + private version = 0; + + public clear() { + this.perFileMap.clear(); + this.version = 0; + } + + public getVersion() { + return this.version; + } + + public remove(path: Path) { + this.perFileMap.remove(path); + this.version++; + } + + public get(path: Path) { + return this.perFileMap.get(path); + } + + public set(path: Path, value: ReadonlyArray) { + this.perFileMap.set(path, value); + this.version++; + } + } + export abstract class Project { private rootFiles: ScriptInfo[] = []; private rootFilesMap: FileMap = createFileMap(); private lsHost: ServerLanguageServiceHost; private program: ts.Program; + private cachedUnresolvedImportsPerFile = new UnresolvedImportsMap(); + private lastCachedUnresolvedImportsList: SortedReadonlyArray; + private languageService: LanguageService; builder: Builder; /** @@ -91,7 +122,7 @@ namespace ts.server { */ private projectStateVersion = 0; - private typingFiles: TypingsArray; + private typingFiles: SortedReadonlyArray; protected projectErrors: Diagnostic[]; @@ -107,6 +138,10 @@ namespace ts.server { return hasOneOrMoreJsAndNoTsFiles(this); } + public getCachedUnresolvedImportsPerFile_TestOnly() { + return this.cachedUnresolvedImportsPerFile; + } + constructor( readonly projectKind: ProjectKind, readonly projectService: ProjectService, @@ -326,6 +361,7 @@ namespace ts.server { removeFile(info: ScriptInfo, detachFromProject = true) { this.removeRootFileIfNecessary(info); this.lsHost.notifyFileRemoved(info); + this.cachedUnresolvedImportsPerFile.remove(info.path); if (detachFromProject) { info.detachFromProject(this); @@ -338,6 +374,38 @@ namespace ts.server { this.projectStateVersion++; } + private extractUnresolvedImportsFromSourceFile(file: SourceFile, result: string[]) { + const cached = this.cachedUnresolvedImportsPerFile.get(file.path); + if (cached) { + // found cached result - use it and return + for (const f of cached) { + result.push(f); + } + return; + } + let unresolvedImports: string[]; + if (file.resolvedModules) { + for (const name in file.resolvedModules) { + // pick unresolved non-relative names + if (!file.resolvedModules[name] && !isExternalModuleNameRelative(name)) { + // for non-scoped names extract part up-to the first slash + // for scoped names - extract up to the second slash + let trimmed = name.trim(); + let i = trimmed.indexOf("/"); + if (i !== -1 && trimmed.charCodeAt(0) === CharacterCodes.at) { + i = trimmed.indexOf("/", i + 1); + } + if (i !== -1) { + trimmed = trimmed.substr(0, i); + } + (unresolvedImports || (unresolvedImports = [])).push(trimmed); + result.push(trimmed); + } + } + } + this.cachedUnresolvedImportsPerFile.set(file.path, unresolvedImports || emptyArray); + } + /** * Updates set of files that contribute to this project * @returns: true if set of files in the project stays the same and false - otherwise. @@ -346,8 +414,35 @@ namespace ts.server { if (!this.languageServiceEnabled) { return true; } + + this.lsHost.startRecordingFilesWithChangedResolutions(); + let hasChanges = this.updateGraphWorker(); - const cachedTypings = this.projectService.typingsCache.getTypingsForProject(this, hasChanges); + + const changedFiles: ReadonlyArray = this.lsHost.finishRecordingFilesWithChangedResolutions() || emptyArray; + + for (const file of changedFiles) { + // delete cached information for changed files + this.cachedUnresolvedImportsPerFile.remove(file); + } + + // 1. no changes in structure, no changes in unresolved imports - do nothing + // 2. no changes in structure, unresolved imports were changed - collect unresolved imports for all files + // (can reuse cached imports for files that were not changed) + // 3. new files were added/removed, but compilation settings stays the same - collect unresolved imports for all new/modified files + // (can reuse cached imports for files that were not changed) + // 4. compilation settings were changed in the way that might affect module resolution - drop all caches and collect all data from the scratch + let unresolvedImports: SortedReadonlyArray; + if (hasChanges || changedFiles.length) { + const result: string[] = []; + for (const sourceFile of this.program.getSourceFiles()) { + this.extractUnresolvedImportsFromSourceFile(sourceFile, result); + } + this.lastCachedUnresolvedImportsList = toSortedReadonlyArray(result); + } + unresolvedImports = this.lastCachedUnresolvedImportsList; + + const cachedTypings = this.projectService.typingsCache.getTypingsForProject(this, unresolvedImports, hasChanges); if (this.setTypings(cachedTypings)) { hasChanges = this.updateGraphWorker() || hasChanges; } @@ -357,7 +452,7 @@ namespace ts.server { return !hasChanges; } - private setTypings(typings: TypingsArray): boolean { + private setTypings(typings: SortedReadonlyArray): boolean { if (arrayIsEqualTo(this.typingFiles, typings)) { return false; } @@ -430,6 +525,11 @@ namespace ts.server { compilerOptions.allowJs = true; } compilerOptions.allowNonTsExtensions = true; + if (changesAffectModuleResolution(this.compilerOptions, compilerOptions)) { + // reset cached unresolved imports if changes in compiler options affected module resolution + this.cachedUnresolvedImportsPerFile.clear(); + this.lastCachedUnresolvedImportsList = undefined; + } this.compilerOptions = compilerOptions; this.lsHost.setCompilationSettings(compilerOptions); diff --git a/src/server/server.ts b/src/server/server.ts index 6ddd267f56e7d..0ce647e37520a 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -181,12 +181,15 @@ namespace ts.server { private installer: NodeChildProcess; private socket: NodeSocket; private projectService: ProjectService; + private throttledOperations: ThrottledOperations; constructor( private readonly logger: server.Logger, + host: ServerHost, eventPort: number, readonly globalTypingsCacheLocation: string, private newLine: string) { + this.throttledOperations = new ThrottledOperations(host); if (eventPort) { const s = net.connect({ port: eventPort }, () => { this.socket = s; @@ -231,12 +234,19 @@ namespace ts.server { this.installer.send({ projectName: p.getProjectName(), kind: "closeProject" }); } - enqueueInstallTypingsRequest(project: Project, typingOptions: TypingOptions): void { - const request = createInstallTypingsRequest(project, typingOptions); + enqueueInstallTypingsRequest(project: Project, typingOptions: TypingOptions, unresolvedImports: SortedReadonlyArray): void { + const request = createInstallTypingsRequest(project, typingOptions, unresolvedImports); if (this.logger.hasLevel(LogLevel.verbose)) { - this.logger.info(`Sending request: ${JSON.stringify(request)}`); + if (this.logger.hasLevel(LogLevel.verbose)) { + this.logger.info(`Scheduling throttled operation: ${JSON.stringify(request)}`); + } } - this.installer.send(request); + this.throttledOperations.schedule(project.getProjectName(), /*ms*/ 250, () => { + if (this.logger.hasLevel(LogLevel.verbose)) { + this.logger.info(`Sending request: ${JSON.stringify(request)}`); + } + this.installer.send(request); + }); } private handleMessage(response: SetTypings | InvalidateCachedTypings) { @@ -266,7 +276,7 @@ namespace ts.server { useSingleInferredProject, disableAutomaticTypingAcquisition ? nullTypingsInstaller - : new NodeTypingsInstaller(logger, installerEventPort, globalTypingsCacheLocation, host.newLine), + : new NodeTypingsInstaller(logger, host, installerEventPort, globalTypingsCacheLocation, host.newLine), Buffer.byteLength, process.hrtime, logger, diff --git a/src/server/types.d.ts b/src/server/types.d.ts index 14af59d5cd3ae..ec2befe8fa9b6 100644 --- a/src/server/types.d.ts +++ b/src/server/types.d.ts @@ -18,6 +18,10 @@ declare namespace ts.server { trace?(s: string): void; } + export interface SortedReadonlyArray extends ReadonlyArray { + " __sortedReadonlyArrayBrand": any; + } + export interface TypingInstallerRequest { readonly projectName: string; readonly kind: "discover" | "closeProject"; @@ -26,8 +30,9 @@ declare namespace ts.server { export interface DiscoverTypings extends TypingInstallerRequest { readonly fileNames: string[]; readonly projectRootPath: ts.Path; - readonly typingOptions: ts.TypingOptions; readonly compilerOptions: ts.CompilerOptions; + readonly typingOptions: ts.TypingOptions; + readonly unresolvedImports: SortedReadonlyArray; readonly cachePath?: string; readonly kind: "discover"; } @@ -36,20 +41,23 @@ declare namespace ts.server { readonly kind: "closeProject"; } + export type SetRequest = "set"; + export type InvalidateRequest = "invalidate"; export interface TypingInstallerResponse { readonly projectName: string; - readonly kind: "set" | "invalidate"; + readonly kind: SetRequest | InvalidateRequest; } export interface SetTypings extends TypingInstallerResponse { readonly typingOptions: ts.TypingOptions; readonly compilerOptions: ts.CompilerOptions; readonly typings: string[]; - readonly kind: "set"; + readonly unresolvedImports: SortedReadonlyArray; + readonly kind: SetRequest; } export interface InvalidateCachedTypings extends TypingInstallerResponse { - readonly kind: "invalidate"; + readonly kind: InvalidateRequest; } export interface InstallTypingHost extends JsTyping.TypingResolutionHost { diff --git a/src/server/typingsCache.ts b/src/server/typingsCache.ts index 04c321b46f9cd..9013622e24ada 100644 --- a/src/server/typingsCache.ts +++ b/src/server/typingsCache.ts @@ -2,7 +2,7 @@ namespace ts.server { export interface ITypingsInstaller { - enqueueInstallTypingsRequest(p: Project, typingOptions: TypingOptions): void; + enqueueInstallTypingsRequest(p: Project, typingOptions: TypingOptions, unresolvedImports: SortedReadonlyArray): void; attach(projectService: ProjectService): void; onProjectClosed(p: Project): void; readonly globalTypingsCacheLocation: string; @@ -18,7 +18,9 @@ namespace ts.server { class TypingsCacheEntry { readonly typingOptions: TypingOptions; readonly compilerOptions: CompilerOptions; - readonly typings: TypingsArray; + readonly typings: SortedReadonlyArray; + readonly unresolvedImports: SortedReadonlyArray; + /* mainly useful for debugging */ poisoned: boolean; } @@ -61,13 +63,11 @@ namespace ts.server { return opt1.allowJs != opt2.allowJs; } - export interface TypingsArray extends ReadonlyArray { - " __typingsArrayBrand": any; - } - - function toTypingsArray(arr: string[]): TypingsArray { - arr.sort(); - return arr; + function unresolvedImportsChanged(imports1: SortedReadonlyArray, imports2: SortedReadonlyArray): boolean { + if (imports1 === imports2) { + return false; + } + return !arrayIsEqualTo(imports1, imports2); } export class TypingsCache { @@ -76,7 +76,7 @@ namespace ts.server { constructor(private readonly installer: ITypingsInstaller) { } - getTypingsForProject(project: Project, forceRefresh: boolean): TypingsArray { + getTypingsForProject(project: Project, unresolvedImports: SortedReadonlyArray, forceRefresh: boolean): SortedReadonlyArray { const typingOptions = project.getTypingOptions(); if (!typingOptions || !typingOptions.enableAutoDiscovery) { @@ -84,39 +84,41 @@ namespace ts.server { } const entry = this.perProjectCache[project.getProjectName()]; - const result: TypingsArray = entry ? entry.typings : emptyArray; - if (forceRefresh || !entry || typingOptionsChanged(typingOptions, entry.typingOptions) || compilerOptionsChanged(project.getCompilerOptions(), entry.compilerOptions)) { + const result: SortedReadonlyArray = entry ? entry.typings : emptyArray; + if (forceRefresh || + !entry || + typingOptionsChanged(typingOptions, entry.typingOptions) || + compilerOptionsChanged(project.getCompilerOptions(), entry.compilerOptions) || + unresolvedImportsChanged(unresolvedImports, entry.unresolvedImports)) { // Note: entry is now poisoned since it does not really contain typings for a given combination of compiler options\typings options. // instead it acts as a placeholder to prevent issuing multiple requests this.perProjectCache[project.getProjectName()] = { compilerOptions: project.getCompilerOptions(), typingOptions, typings: result, + unresolvedImports, poisoned: true }; // something has been changed, issue a request to update typings - this.installer.enqueueInstallTypingsRequest(project, typingOptions); + this.installer.enqueueInstallTypingsRequest(project, typingOptions, unresolvedImports); } return result; } - invalidateCachedTypingsForProject(project: Project) { - const typingOptions = project.getTypingOptions(); - if (!typingOptions.enableAutoDiscovery) { - return; - } - this.installer.enqueueInstallTypingsRequest(project, typingOptions); - } - - updateTypingsForProject(projectName: string, compilerOptions: CompilerOptions, typingOptions: TypingOptions, newTypings: string[]) { + updateTypingsForProject(projectName: string, compilerOptions: CompilerOptions, typingOptions: TypingOptions, unresolvedImports: SortedReadonlyArray, newTypings: string[]) { this.perProjectCache[projectName] = { compilerOptions, typingOptions, - typings: toTypingsArray(newTypings), + typings: toSortedReadonlyArray(newTypings), + unresolvedImports, poisoned: false }; } + deleteTypingsForProject(projectName: string) { + delete this.perProjectCache[projectName]; + } + onProjectClosed(project: Project) { delete this.perProjectCache[project.getProjectName()]; this.installer.onProjectClosed(project); diff --git a/src/server/typingsInstaller/typingsInstaller.ts b/src/server/typingsInstaller/typingsInstaller.ts index 21f85c241a6e9..df043fc26ae06 100644 --- a/src/server/typingsInstaller/typingsInstaller.ts +++ b/src/server/typingsInstaller/typingsInstaller.ts @@ -26,6 +26,7 @@ namespace ts.server.typingsInstaller { export enum PackageNameValidationResult { Ok, ScopedPackagesNotSupported, + EmptyName, NameTooLong, NameStartsWithDot, NameStartsWithUnderscore, @@ -38,7 +39,9 @@ namespace ts.server.typingsInstaller { * Validates package name using rules defined at https://docs.npmjs.com/files/package.json */ export function validatePackageName(packageName: string): PackageNameValidationResult { - Debug.assert(!!packageName, "Package name is not specified"); + if (!packageName) { + return PackageNameValidationResult.EmptyName; + } if (packageName.length > MaxPackageNameLength) { return PackageNameValidationResult.NameTooLong; } @@ -145,7 +148,8 @@ namespace ts.server.typingsInstaller { req.projectRootPath, this.safeListPath, this.packageNameToTypingLocation, - req.typingOptions); + req.typingOptions, + req.unresolvedImports); if (this.log.isEnabled()) { this.log.writeLine(`Finished typings discovery: ${JSON.stringify(discoverTypingsResult)}`); @@ -238,6 +242,9 @@ namespace ts.server.typingsInstaller { this.missingTypingsSet[typing] = true; if (this.log.isEnabled()) { switch (validationResult) { + case PackageNameValidationResult.EmptyName: + this.log.writeLine(`Package name '${typing}' cannot be empty`); + break; case PackageNameValidationResult.NameTooLong: this.log.writeLine(`Package name '${typing}' should be less than ${MaxPackageNameLength} characters`); break; @@ -397,6 +404,7 @@ namespace ts.server.typingsInstaller { typingOptions: request.typingOptions, compilerOptions: request.compilerOptions, typings, + unresolvedImports: request.unresolvedImports, kind: "set" }; } diff --git a/src/server/utilities.ts b/src/server/utilities.ts index 5bd3423e57010..8806b759e3f8d 100644 --- a/src/server/utilities.ts +++ b/src/server/utilities.ts @@ -45,12 +45,13 @@ namespace ts.server { } } - export function createInstallTypingsRequest(project: Project, typingOptions: TypingOptions, cachePath?: string): DiscoverTypings { + export function createInstallTypingsRequest(project: Project, typingOptions: TypingOptions, unresolvedImports: SortedReadonlyArray, cachePath?: string): DiscoverTypings { return { projectName: project.getProjectName(), fileNames: project.getFileNames(), compilerOptions: project.getCompilerOptions(), typingOptions, + unresolvedImports, projectRootPath: getProjectRootPath(project), cachePath, kind: "discover" @@ -209,11 +210,15 @@ namespace ts.server { export interface ServerLanguageServiceHost { setCompilationSettings(options: CompilerOptions): void; notifyFileRemoved(info: ScriptInfo): void; + startRecordingFilesWithChangedResolutions(): void; + finishRecordingFilesWithChangedResolutions(): Path[]; } export const nullLanguageServiceHost: ServerLanguageServiceHost = { setCompilationSettings: () => undefined, - notifyFileRemoved: () => undefined + notifyFileRemoved: () => undefined, + startRecordingFilesWithChangedResolutions: () => undefined, + finishRecordingFilesWithChangedResolutions: () => undefined }; export interface ProjectOptions { @@ -240,6 +245,11 @@ namespace ts.server { return `/dev/null/inferredProject${counter}*`; } + export function toSortedReadonlyArray(arr: string[]): SortedReadonlyArray { + arr.sort(); + return arr; + } + export class ThrottledOperations { private pendingTimeouts: Map = createMap(); constructor(private readonly host: ServerHost) { diff --git a/src/services/jsTyping.ts b/src/services/jsTyping.ts index 561e0110cdaa5..0f635c151740a 100644 --- a/src/services/jsTyping.ts +++ b/src/services/jsTyping.ts @@ -31,6 +31,17 @@ namespace ts.JsTyping { const EmptySafeList: Map = createMap(); + /* @internal */ + export const nodeCoreModuleList: ReadonlyArray = [ + "buffer", "querystring", "events", "http", "cluster", + "zlib", "os", "https", "punycode", "repl", "readline", + "vm", "child_process", "url", "dns", "net", + "dgram", "fs", "path", "string_decoder", "tls", + "crypto", "stream", "util", "assert", "tty", "domain", + "constants", "process", "v8", "timers", "console"]; + + const nodeCoreModules = arrayToMap(nodeCoreModuleList, x => x); + /** * @param host is the object providing I/O related operations. * @param fileNames are the file names that belong to the same project @@ -46,7 +57,8 @@ namespace ts.JsTyping { projectRootPath: Path, safeListPath: Path, packageNameToTypingLocation: Map, - typingOptions: TypingOptions): + typingOptions: TypingOptions, + unresolvedImports: ReadonlyArray): { cachedTypingPaths: string[], newTypingNames: string[], filesToWatch: string[] } { // A typing name to typing file path mapping @@ -92,6 +104,15 @@ namespace ts.JsTyping { } getTypingNamesFromSourceFileNames(fileNames); + // add typings for unresolved imports + if (unresolvedImports) { + for (const moduleId of unresolvedImports) { + const typingName = moduleId in nodeCoreModules ? "node" : moduleId; + if (!(typingName in inferredTypings)) { + inferredTypings[typingName] = undefined; + } + } + } // Add the cached typing locations for inferred typings that are already installed for (const name in packageNameToTypingLocation) { if (name in inferredTypings && !inferredTypings[name]) { diff --git a/src/services/shims.ts b/src/services/shims.ts index bd4c48838dabc..16a6592844830 100644 --- a/src/services/shims.ts +++ b/src/services/shims.ts @@ -1168,7 +1168,8 @@ namespace ts { toPath(info.projectRootPath, info.projectRootPath, getCanonicalFileName), toPath(info.safeListPath, info.safeListPath, getCanonicalFileName), info.packageNameToTypingLocation, - info.typingOptions); + info.typingOptions, + info.unresolvedImports); }); } }