diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index bda8f1d09a..1e75957fee 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -407,6 +407,12 @@ namespace ts.projectSystem { checkArray("Open files", arrayFrom(projectService.openFiles.keys(), path => projectService.getScriptInfoForPath(path as Path).fileName), expectedFiles.map(file => file.path)); } + function textSpanFromSubstring(str: string, substring: string): TextSpan { + const start = str.indexOf(substring); + Debug.assert(start !== -1); + return createTextSpan(start, substring.length); + } + /** * Test server cancellation token used to mock host token cancellation requests. * The cancelAfterRequest constructor param specifies how many isCancellationRequested() calls @@ -8420,9 +8426,96 @@ new C();` }); }); - function textSpanFromSubstring(str: string, substring: string): TextSpan { - const start = str.indexOf(substring); - Debug.assert(start !== -1); - return createTextSpan(start, substring.length); - } + describe("document registry in project service", () => { + const projectRootPath = "/user/username/projects/project"; + const importModuleContent = `import {a} from "./module1"`; + const file: File = { + path: `${projectRootPath}/index.ts`, + content: importModuleContent + }; + const moduleFile: File = { + path: `${projectRootPath}/module1.d.ts`, + content: "export const a: number;" + }; + const configFile: File = { + path: `${projectRootPath}/tsconfig.json`, + content: JSON.stringify({ files: ["index.ts"] }) + }; + + function getProject(service: TestProjectService) { + return service.configuredProjects.get(configFile.path); + } + + function checkProject(service: TestProjectService, moduleIsOrphan: boolean) { + // Update the project + const project = getProject(service); + project.getLanguageService(); + checkProjectActualFiles(project, [file.path, libFile.path, configFile.path, ...(moduleIsOrphan ? [] : [moduleFile.path])]); + const moduleInfo = service.getScriptInfo(moduleFile.path); + assert.isDefined(moduleInfo); + assert.equal(moduleInfo.isOrphan(), moduleIsOrphan); + const key = service.documentRegistry.getKeyForCompilationSettings(project.getCompilationSettings()); + assert.deepEqual(service.documentRegistry.getLanguageServiceRefCounts(moduleInfo.path), [[key, moduleIsOrphan ? undefined : 1]]); + } + + function createServiceAndHost() { + const host = createServerHost([file, moduleFile, libFile, configFile]); + const service = createProjectService(host); + service.openClientFile(file.path); + checkProject(service, /*moduleIsOrphan*/ false); + return { host, service }; + } + + function changeFileToNotImportModule(service: TestProjectService) { + const info = service.getScriptInfo(file.path); + service.applyChangesToFile(info, [{ span: { start: 0, length: importModuleContent.length }, newText: "" }]); + checkProject(service, /*moduleIsOrphan*/ true); + } + + function changeFileToImportModule(service: TestProjectService) { + const info = service.getScriptInfo(file.path); + service.applyChangesToFile(info, [{ span: { start: 0, length: 0 }, newText: importModuleContent }]); + checkProject(service, /*moduleIsOrphan*/ false); + } + + it("Caches the source file if script info is orphan", () => { + const { service } = createServiceAndHost(); + const project = getProject(service); + + const moduleInfo = service.getScriptInfo(moduleFile.path); + const sourceFile = moduleInfo.cacheSourceFile.sourceFile; + assert.equal(project.getSourceFile(moduleInfo.path), sourceFile); + + // edit file + changeFileToNotImportModule(service); + assert.equal(moduleInfo.cacheSourceFile.sourceFile, sourceFile); + + // write content back + changeFileToImportModule(service); + assert.equal(moduleInfo.cacheSourceFile.sourceFile, sourceFile); + assert.equal(project.getSourceFile(moduleInfo.path), sourceFile); + }); + + it("Caches the source file if script info is orphan, and orphan script info changes", () => { + const { host, service } = createServiceAndHost(); + const project = getProject(service); + + const moduleInfo = service.getScriptInfo(moduleFile.path); + const sourceFile = moduleInfo.cacheSourceFile.sourceFile; + assert.equal(project.getSourceFile(moduleInfo.path), sourceFile); + + // edit file + changeFileToNotImportModule(service); + assert.equal(moduleInfo.cacheSourceFile.sourceFile, sourceFile); + + const updatedModuleContent = moduleFile.content + "\nexport const b: number;"; + host.writeFile(moduleFile.path, updatedModuleContent); + + // write content back + changeFileToImportModule(service); + assert.notEqual(moduleInfo.cacheSourceFile.sourceFile, sourceFile); + assert.equal(project.getSourceFile(moduleInfo.path), moduleInfo.cacheSourceFile.sourceFile); + assert.equal(moduleInfo.cacheSourceFile.sourceFile.text, updatedModuleContent); + }); + }); } diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 094e9ad186..71c5791187 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -339,7 +339,8 @@ namespace ts.server { /*@internal*/ readonly typingsCache: TypingsCache; - private readonly documentRegistry: DocumentRegistry; + /*@internal*/ + readonly documentRegistry: DocumentRegistry; /** * Container of all known scripts @@ -474,7 +475,7 @@ namespace ts.server { extraFileExtensions: [] }; - this.documentRegistry = createDocumentRegistry(this.host.useCaseSensitiveFileNames, this.currentDirectory); + this.documentRegistry = createDocumentRegistryInternal(this.host.useCaseSensitiveFileNames, this.currentDirectory, this); 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; @@ -495,6 +496,19 @@ namespace ts.server { return getNormalizedAbsolutePath(fileName, this.host.getCurrentDirectory()); } + /*@internal*/ + setDocument(key: DocumentRegistryBucketKey, path: Path, sourceFile: SourceFile) { + const info = this.getScriptInfoForPath(path); + Debug.assert(!!info); + info.cacheSourceFile = { key, sourceFile }; + } + + /*@internal*/ + getDocument(key: DocumentRegistryBucketKey, path: Path) { + const info = this.getScriptInfoForPath(path); + return info && info.cacheSourceFile && info.cacheSourceFile.key === key && info.cacheSourceFile.sourceFile; + } + /* @internal */ ensureInferredProjectsUpToDate_TestOnly() { this.ensureProjectStructuresUptoDate(); diff --git a/src/server/scriptInfo.ts b/src/server/scriptInfo.ts index efae3a5faa..c47f3daecb 100644 --- a/src/server/scriptInfo.ts +++ b/src/server/scriptInfo.ts @@ -202,6 +202,12 @@ namespace ts.server { return fileName[0] === "^" || getBaseFileName(fileName)[0] === "^"; } + /*@internal*/ + export interface DocumentRegistrySourceFileCache { + key: DocumentRegistryBucketKey; + sourceFile: SourceFile; + } + export class ScriptInfo { /** * All projects that include this file @@ -221,6 +227,9 @@ namespace ts.server { /** Set to real path if path is different from info.path */ private realpath: Path | undefined; + /*@internal*/ + cacheSourceFile: DocumentRegistrySourceFileCache; + constructor( private readonly host: ServerHost, readonly fileName: NormalizedPath, diff --git a/src/services/documentRegistry.ts b/src/services/documentRegistry.ts index d464f7a408..05f6548c62 100644 --- a/src/services/documentRegistry.ts +++ b/src/services/documentRegistry.ts @@ -87,9 +87,18 @@ namespace ts { releaseDocumentWithKey(path: Path, key: DocumentRegistryBucketKey): void; + /*@internal*/ + getLanguageServiceRefCounts(path: Path): [string, number | undefined][]; + reportStats(): string; } + /*@internal*/ + export interface ExternalDocumentCache { + setDocument(key: DocumentRegistryBucketKey, path: Path, sourceFile: SourceFile): void; + getDocument(key: DocumentRegistryBucketKey, path: Path): SourceFile | undefined; + } + export type DocumentRegistryBucketKey = string & { __bucketKey: any }; interface DocumentRegistryEntry { @@ -99,10 +108,14 @@ namespace ts { // language services are referencing the file, then the file can be removed from the // registry. languageServiceRefCount: number; - owners: string[]; } - export function createDocumentRegistry(useCaseSensitiveFileNames?: boolean, currentDirectory = ""): DocumentRegistry { + export function createDocumentRegistry(useCaseSensitiveFileNames?: boolean, currentDirectory?: string): DocumentRegistry { + return createDocumentRegistryInternal(useCaseSensitiveFileNames, currentDirectory); + } + + /*@internal*/ + export function createDocumentRegistryInternal(useCaseSensitiveFileNames?: boolean, currentDirectory = "", externalCache?: ExternalDocumentCache): DocumentRegistry { // Maps from compiler setting target (ES3, ES5, etc.) to all the cached documents we have // for those settings. const buckets = createMap>(); @@ -123,12 +136,11 @@ namespace ts { function reportStats() { const bucketInfoArray = arrayFrom(buckets.keys()).filter(name => name && name.charAt(0) === "_").map(name => { const entries = buckets.get(name); - const sourceFiles: { name: string; refCount: number; references: string[]; }[] = []; + const sourceFiles: { name: string; refCount: number; }[] = []; entries.forEach((entry, name) => { sourceFiles.push({ name, - refCount: entry.languageServiceRefCount, - references: entry.owners.slice(0) + refCount: entry.languageServiceRefCount }); }); sourceFiles.sort((x, y) => y.refCount - x.refCount); @@ -173,14 +185,27 @@ namespace ts { const bucket = getBucketForCompilationSettings(key, /*createIfMissing*/ true); let entry = bucket.get(path); const scriptTarget = scriptKind === ScriptKind.JSON ? ScriptTarget.JSON : compilationSettings.target; + if (!entry && externalCache) { + const sourceFile = externalCache.getDocument(key, path); + if (sourceFile) { + Debug.assert(acquiring); + entry = { + sourceFile, + languageServiceRefCount: 0 + }; + bucket.set(path, entry); + } + } + if (!entry) { // Have never seen this file with these settings. Create a new source file for it. const sourceFile = createLanguageServiceSourceFile(fileName, scriptSnapshot, scriptTarget, version, /*setNodeParents*/ false, scriptKind); - + if (externalCache) { + externalCache.setDocument(key, path, sourceFile); + } entry = { sourceFile, languageServiceRefCount: 1, - owners: [] }; bucket.set(path, entry); } @@ -191,6 +216,9 @@ namespace ts { if (entry.sourceFile.version !== version) { entry.sourceFile = updateLanguageServiceSourceFile(entry.sourceFile, scriptSnapshot, version, scriptSnapshot.getChangeRange(entry.sourceFile.scriptSnapshot)); + if (externalCache) { + externalCache.setDocument(key, path, entry.sourceFile); + } } // If we're acquiring, then this is the first time this LS is asking for this document. @@ -202,6 +230,7 @@ namespace ts { entry.languageServiceRefCount++; } } + Debug.assert(entry.languageServiceRefCount !== 0); return entry.sourceFile; } @@ -225,6 +254,13 @@ namespace ts { } } + function getLanguageServiceRefCounts(path: Path) { + return arrayFrom(buckets.entries(), ([key, bucket]): [string, number | undefined] => { + const entry = bucket.get(path); + return [key, entry && entry.languageServiceRefCount]; + }); + } + return { acquireDocument, acquireDocumentWithKey, @@ -232,6 +268,7 @@ namespace ts { updateDocumentWithKey, releaseDocument, releaseDocumentWithKey, + getLanguageServiceRefCounts, reportStats, getKeyForCompilationSettings }; diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 8516bd7c31..3b36440877 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -8134,7 +8134,6 @@ declare namespace ts.server { syntaxOnly?: boolean; } class ProjectService { - private readonly documentRegistry; /** * Container of all known scripts */