1namespace ts.server { 2 export interface ScriptInfoVersion { 3 svc: number; 4 text: number; 5 } 6 7 /* @internal */ 8 export class TextStorage { 9 version: ScriptInfoVersion; 10 11 /** 12 * Generated only on demand (based on edits, or information requested) 13 * The property text is set to undefined when edits happen on the cache 14 */ 15 private svc: ScriptVersionCache | undefined; 16 17 /** 18 * Stores the text when there are no changes to the script version cache 19 * The script version cache is generated on demand and text is still retained. 20 * Only on edits to the script version cache, the text will be set to undefined 21 */ 22 private text: string | undefined; 23 /** 24 * Line map for the text when there is no script version cache present 25 */ 26 private lineMap: number[] | undefined; 27 28 /** 29 * When a large file is loaded, text will artificially be set to "". 30 * In order to be able to report correct telemetry, we store the actual 31 * file size in this case. (In other cases where text === "", e.g. 32 * for mixed content or dynamic files, fileSize will be undefined.) 33 */ 34 private fileSize: number | undefined; 35 36 /** 37 * True if the text is for the file thats open in the editor 38 */ 39 public isOpen = false; 40 /** 41 * True if the text present is the text from the file on the disk 42 */ 43 private ownFileText = false; 44 /** 45 * True when reloading contents of file from the disk is pending 46 */ 47 private pendingReloadFromDisk = false; 48 49 constructor(private readonly host: ServerHost, private readonly info: ScriptInfo, initialVersion?: ScriptInfoVersion) { 50 this.version = initialVersion || { svc: 0, text: 0 }; 51 } 52 53 public getVersion() { 54 return this.svc 55 ? `SVC-${this.version.svc}-${this.svc.getSnapshotVersion()}` 56 : `Text-${this.version.text}`; 57 } 58 59 public hasScriptVersionCache_TestOnly() { 60 return this.svc !== undefined; 61 } 62 63 public useScriptVersionCache_TestOnly() { 64 this.switchToScriptVersionCache(); 65 } 66 67 private resetSourceMapInfo() { 68 this.info.sourceFileLike = undefined; 69 this.info.closeSourceMapFileWatcher(); 70 this.info.sourceMapFilePath = undefined; 71 this.info.declarationInfoPath = undefined; 72 this.info.sourceInfos = undefined; 73 this.info.documentPositionMapper = undefined; 74 } 75 76 /** Public for testing */ 77 public useText(newText?: string) { 78 this.svc = undefined; 79 this.text = newText; 80 this.lineMap = undefined; 81 this.fileSize = undefined; 82 this.resetSourceMapInfo(); 83 this.version.text++; 84 } 85 86 public edit(start: number, end: number, newText: string) { 87 this.switchToScriptVersionCache().edit(start, end - start, newText); 88 this.ownFileText = false; 89 this.text = undefined; 90 this.lineMap = undefined; 91 this.fileSize = undefined; 92 this.resetSourceMapInfo(); 93 } 94 95 /** 96 * Set the contents as newText 97 * returns true if text changed 98 */ 99 public reload(newText: string): boolean { 100 Debug.assert(newText !== undefined); 101 102 // Reload always has fresh content 103 this.pendingReloadFromDisk = false; 104 105 // If text changed set the text 106 // This also ensures that if we had switched to version cache, 107 // we are switching back to text. 108 // The change to version cache will happen when needed 109 // Thus avoiding the computation if there are no changes 110 if (this.text !== newText) { 111 this.useText(newText); 112 // We cant guarantee new text is own file text 113 this.ownFileText = false; 114 return true; 115 } 116 117 return false; 118 } 119 120 /** 121 * Reads the contents from tempFile(if supplied) or own file and sets it as contents 122 * returns true if text changed 123 */ 124 public reloadWithFileText(tempFileName?: string) { 125 const { text: newText, fileSize } = this.getFileTextAndSize(tempFileName); 126 const reloaded = this.reload(newText); 127 this.fileSize = fileSize; // NB: after reload since reload clears it 128 this.ownFileText = !tempFileName || tempFileName === this.info.fileName; 129 return reloaded; 130 } 131 132 /** 133 * Reloads the contents from the file if there is no pending reload from disk or the contents of file are same as file text 134 * returns true if text changed 135 */ 136 public reloadFromDisk() { 137 if (!this.pendingReloadFromDisk && !this.ownFileText) { 138 return this.reloadWithFileText(); 139 } 140 return false; 141 } 142 143 public delayReloadFromFileIntoText() { 144 this.pendingReloadFromDisk = true; 145 } 146 147 /** 148 * For telemetry purposes, we would like to be able to report the size of the file. 149 * However, we do not want telemetry to require extra file I/O so we report a size 150 * that may be stale (e.g. may not reflect change made on disk since the last reload). 151 * NB: Will read from disk if the file contents have never been loaded because 152 * telemetry falsely indicating size 0 would be counter-productive. 153 */ 154 public getTelemetryFileSize(): number { 155 return !!this.fileSize 156 ? this.fileSize 157 : !!this.text // Check text before svc because its length is cheaper 158 ? this.text.length // Could be wrong if this.pendingReloadFromDisk 159 : !!this.svc 160 ? this.svc.getSnapshot().getLength() // Could be wrong if this.pendingReloadFromDisk 161 : this.getSnapshot().getLength(); // Should be strictly correct 162 } 163 164 public getSnapshot(): IScriptSnapshot { 165 return this.useScriptVersionCacheIfValidOrOpen() 166 ? this.svc!.getSnapshot() 167 : ScriptSnapshot.fromString(this.getOrLoadText()); 168 } 169 170 public getAbsolutePositionAndLineText(line: number): AbsolutePositionAndLineText { 171 return this.switchToScriptVersionCache().getAbsolutePositionAndLineText(line); 172 } 173 /** 174 * @param line 0 based index 175 */ 176 lineToTextSpan(line: number): TextSpan { 177 if (!this.useScriptVersionCacheIfValidOrOpen()) { 178 const lineMap = this.getLineMap(); 179 const start = lineMap[line]; // -1 since line is 1-based 180 const end = line + 1 < lineMap.length ? lineMap[line + 1] : this.text!.length; 181 return createTextSpanFromBounds(start, end); 182 } 183 return this.svc!.lineToTextSpan(line); 184 } 185 186 /** 187 * @param line 1 based index 188 * @param offset 1 based index 189 */ 190 lineOffsetToPosition(line: number, offset: number, allowEdits?: true): number { 191 if (!this.useScriptVersionCacheIfValidOrOpen()) { 192 return computePositionOfLineAndCharacter(this.getLineMap(), line - 1, offset - 1, this.text, allowEdits); 193 } 194 195 // TODO: assert this offset is actually on the line 196 return this.svc!.lineOffsetToPosition(line, offset); 197 } 198 199 positionToLineOffset(position: number): protocol.Location { 200 if (!this.useScriptVersionCacheIfValidOrOpen()) { 201 const { line, character } = computeLineAndCharacterOfPosition(this.getLineMap(), position); 202 return { line: line + 1, offset: character + 1 }; 203 } 204 return this.svc!.positionToLineOffset(position); 205 } 206 207 private getFileTextAndSize(tempFileName?: string): { text: string, fileSize?: number } { 208 let text: string; 209 const fileName = tempFileName || this.info.fileName; 210 const getText = () => text === undefined ? (text = this.host.readFile(fileName) || "") : text; 211 // Only non typescript files have size limitation 212 if (!hasTSFileExtension(this.info.fileName)) { 213 const fileSize = this.host.getFileSize ? this.host.getFileSize(fileName) : getText().length; 214 if (fileSize > maxFileSize) { 215 Debug.assert(!!this.info.containingProjects.length); 216 const service = this.info.containingProjects[0].projectService; 217 service.logger.info(`Skipped loading contents of large file ${fileName} for info ${this.info.fileName}: fileSize: ${fileSize}`); 218 this.info.containingProjects[0].projectService.sendLargeFileReferencedEvent(fileName, fileSize); 219 return { text: "", fileSize }; 220 } 221 } 222 return { text: getText() }; 223 } 224 225 private switchToScriptVersionCache(): ScriptVersionCache { 226 if (!this.svc || this.pendingReloadFromDisk) { 227 this.svc = ScriptVersionCache.fromString(this.getOrLoadText()); 228 this.version.svc++; 229 } 230 return this.svc; 231 } 232 233 private useScriptVersionCacheIfValidOrOpen(): ScriptVersionCache | undefined { 234 // If this is open script, use the cache 235 if (this.isOpen) { 236 return this.switchToScriptVersionCache(); 237 } 238 239 // If there is pending reload from the disk then, reload the text 240 if (this.pendingReloadFromDisk) { 241 this.reloadWithFileText(); 242 } 243 244 // At this point if svc is present it's valid 245 return this.svc; 246 } 247 248 private getOrLoadText() { 249 if (this.text === undefined || this.pendingReloadFromDisk) { 250 Debug.assert(!this.svc || this.pendingReloadFromDisk, "ScriptVersionCache should not be set when reloading from disk"); 251 this.reloadWithFileText(); 252 } 253 return this.text!; 254 } 255 256 private getLineMap() { 257 Debug.assert(!this.svc, "ScriptVersionCache should not be set"); 258 return this.lineMap || (this.lineMap = computeLineStarts(this.getOrLoadText())); 259 } 260 261 getLineInfo(): LineInfo { 262 if (this.svc) { 263 return { 264 getLineCount: () => this.svc!.getLineCount(), 265 getLineText: line => this.svc!.getAbsolutePositionAndLineText(line + 1).lineText! 266 }; 267 } 268 const lineMap = this.getLineMap(); 269 return getLineInfo(this.text!, lineMap); 270 } 271 } 272 273 export function isDynamicFileName(fileName: NormalizedPath) { 274 return fileName[0] === "^" || 275 ((stringContains(fileName, "walkThroughSnippet:/") || stringContains(fileName, "untitled:/")) && 276 getBaseFileName(fileName)[0] === "^") || 277 (stringContains(fileName, ":^") && !stringContains(fileName, directorySeparator)); 278 } 279 280 /*@internal*/ 281 export interface DocumentRegistrySourceFileCache { 282 key: DocumentRegistryBucketKeyWithMode; 283 sourceFile: SourceFile; 284 } 285 286 /*@internal*/ 287 export interface SourceMapFileWatcher { 288 watcher: FileWatcher; 289 sourceInfos?: Set<Path>; 290 } 291 292 export class ScriptInfo { 293 /** 294 * All projects that include this file 295 */ 296 readonly containingProjects: Project[] = []; 297 private formatSettings: FormatCodeSettings | undefined; 298 private preferences: protocol.UserPreferences | undefined; 299 300 /* @internal */ 301 fileWatcher: FileWatcher | undefined; 302 private textStorage: TextStorage; 303 304 /*@internal*/ 305 readonly isDynamic: boolean; 306 307 /*@internal*/ 308 /** Set to real path if path is different from info.path */ 309 private realpath: Path | undefined; 310 311 /*@internal*/ 312 cacheSourceFile: DocumentRegistrySourceFileCache | undefined; 313 314 /*@internal*/ 315 mTime?: number; 316 317 /*@internal*/ 318 sourceFileLike?: SourceFileLike; 319 320 /*@internal*/ 321 sourceMapFilePath?: Path | SourceMapFileWatcher | false; 322 323 // Present on sourceMapFile info 324 /*@internal*/ 325 declarationInfoPath?: Path; 326 /*@internal*/ 327 sourceInfos?: Set<Path>; 328 /*@internal*/ 329 documentPositionMapper?: DocumentPositionMapper | false; 330 331 constructor( 332 private readonly host: ServerHost, 333 readonly fileName: NormalizedPath, 334 readonly scriptKind: ScriptKind, 335 public readonly hasMixedContent: boolean, 336 readonly path: Path, 337 initialVersion?: ScriptInfoVersion) { 338 this.isDynamic = isDynamicFileName(fileName); 339 340 this.textStorage = new TextStorage(host, this, initialVersion); 341 if (hasMixedContent || this.isDynamic) { 342 this.textStorage.reload(""); 343 this.realpath = this.path; 344 } 345 this.scriptKind = scriptKind 346 ? scriptKind 347 : getScriptKindFromFileName(fileName); 348 } 349 350 /*@internal*/ 351 getVersion() { 352 return this.textStorage.version; 353 } 354 355 /*@internal*/ 356 getTelemetryFileSize() { 357 return this.textStorage.getTelemetryFileSize(); 358 } 359 360 /*@internal*/ 361 public isDynamicOrHasMixedContent() { 362 return this.hasMixedContent || this.isDynamic; 363 } 364 365 public isScriptOpen() { 366 return this.textStorage.isOpen; 367 } 368 369 public open(newText: string) { 370 this.textStorage.isOpen = true; 371 if (newText !== undefined && 372 this.textStorage.reload(newText)) { 373 // reload new contents only if the existing contents changed 374 this.markContainingProjectsAsDirty(); 375 } 376 } 377 378 public close(fileExists = true) { 379 this.textStorage.isOpen = false; 380 if (this.isDynamicOrHasMixedContent() || !fileExists) { 381 if (this.textStorage.reload("")) { 382 this.markContainingProjectsAsDirty(); 383 } 384 } 385 else if (this.textStorage.reloadFromDisk()) { 386 this.markContainingProjectsAsDirty(); 387 } 388 } 389 390 public getSnapshot() { 391 return this.textStorage.getSnapshot(); 392 } 393 394 private ensureRealPath() { 395 if (this.realpath === undefined) { 396 // Default is just the path 397 this.realpath = this.path; 398 if (this.host.realpath) { 399 Debug.assert(!!this.containingProjects.length); 400 const project = this.containingProjects[0]; 401 const realpath = this.host.realpath(this.path); 402 if (realpath) { 403 this.realpath = project.toPath(realpath); 404 // If it is different from this.path, add to the map 405 if (this.realpath !== this.path) { 406 project.projectService.realpathToScriptInfos!.add(this.realpath, this); // TODO: GH#18217 407 } 408 } 409 } 410 } 411 } 412 413 /*@internal*/ 414 getRealpathIfDifferent(): Path | undefined { 415 return this.realpath && this.realpath !== this.path ? this.realpath : undefined; 416 } 417 418 /** 419 * @internal 420 * Does not compute realpath; uses precomputed result. Use `ensureRealPath` 421 * first if a definite result is needed. 422 */ 423 isSymlink(): boolean | undefined { 424 return this.realpath && this.realpath !== this.path; 425 } 426 427 getFormatCodeSettings(): FormatCodeSettings | undefined { return this.formatSettings; } 428 getPreferences(): protocol.UserPreferences | undefined { return this.preferences; } 429 430 attachToProject(project: Project): boolean { 431 const isNew = !this.isAttached(project); 432 if (isNew) { 433 this.containingProjects.push(project); 434 if (!project.getCompilerOptions().preserveSymlinks) { 435 this.ensureRealPath(); 436 } 437 project.onFileAddedOrRemoved(this.isSymlink()); 438 } 439 return isNew; 440 } 441 442 isAttached(project: Project) { 443 // unrolled for common cases 444 switch (this.containingProjects.length) { 445 case 0: return false; 446 case 1: return this.containingProjects[0] === project; 447 case 2: return this.containingProjects[0] === project || this.containingProjects[1] === project; 448 default: return contains(this.containingProjects, project); 449 } 450 } 451 452 detachFromProject(project: Project) { 453 // unrolled for common cases 454 switch (this.containingProjects.length) { 455 case 0: 456 return; 457 case 1: 458 if (this.containingProjects[0] === project) { 459 project.onFileAddedOrRemoved(this.isSymlink()); 460 this.containingProjects.pop(); 461 } 462 break; 463 case 2: 464 if (this.containingProjects[0] === project) { 465 project.onFileAddedOrRemoved(this.isSymlink()); 466 this.containingProjects[0] = this.containingProjects.pop()!; 467 } 468 else if (this.containingProjects[1] === project) { 469 project.onFileAddedOrRemoved(this.isSymlink()); 470 this.containingProjects.pop(); 471 } 472 break; 473 default: 474 if (unorderedRemoveItem(this.containingProjects, project)) { 475 project.onFileAddedOrRemoved(this.isSymlink()); 476 } 477 break; 478 } 479 } 480 481 detachAllProjects() { 482 for (const p of this.containingProjects) { 483 if (isConfiguredProject(p)) { 484 p.getCachedDirectoryStructureHost().addOrDeleteFile(this.fileName, this.path, FileWatcherEventKind.Deleted); 485 } 486 const existingRoot = p.getRootFilesMap().get(this.path); 487 // detach is unnecessary since we'll clean the list of containing projects anyways 488 p.removeFile(this, /*fileExists*/ false, /*detachFromProjects*/ false); 489 p.onFileAddedOrRemoved(this.isSymlink()); 490 // If the info was for the external or configured project's root, 491 // add missing file as the root 492 if (existingRoot && !isInferredProject(p)) { 493 p.addMissingFileRoot(existingRoot.fileName); 494 } 495 } 496 clear(this.containingProjects); 497 } 498 499 getDefaultProject() { 500 switch (this.containingProjects.length) { 501 case 0: 502 return Errors.ThrowNoProject(); 503 case 1: 504 return ensurePrimaryProjectKind(this.containingProjects[0]); 505 default: 506 // If this file belongs to multiple projects, below is the order in which default project is used 507 // - for open script info, its default configured project during opening is default if info is part of it 508 // - first configured project of which script info is not a source of project reference redirect 509 // - first configured project 510 // - first external project 511 // - first inferred project 512 let firstExternalProject: ExternalProject | undefined; 513 let firstConfiguredProject: ConfiguredProject | undefined; 514 let firstInferredProject: InferredProject | undefined; 515 let firstNonSourceOfProjectReferenceRedirect: ConfiguredProject | undefined; 516 let defaultConfiguredProject: ConfiguredProject | false | undefined; 517 for (let index = 0; index < this.containingProjects.length; index++) { 518 const project = this.containingProjects[index]; 519 if (isConfiguredProject(project)) { 520 if (!project.isSourceOfProjectReferenceRedirect(this.fileName)) { 521 // If we havent found default configuredProject and 522 // its not the last one, find it and use that one if there 523 if (defaultConfiguredProject === undefined && 524 index !== this.containingProjects.length - 1) { 525 defaultConfiguredProject = project.projectService.findDefaultConfiguredProject(this) || false; 526 } 527 if (defaultConfiguredProject === project) return project; 528 if (!firstNonSourceOfProjectReferenceRedirect) firstNonSourceOfProjectReferenceRedirect = project; 529 } 530 if (!firstConfiguredProject) firstConfiguredProject = project; 531 } 532 else if (!firstExternalProject && isExternalProject(project)) { 533 firstExternalProject = project; 534 } 535 else if (!firstInferredProject && isInferredProject(project)) { 536 firstInferredProject = project; 537 } 538 } 539 return ensurePrimaryProjectKind(defaultConfiguredProject || 540 firstNonSourceOfProjectReferenceRedirect || 541 firstConfiguredProject || 542 firstExternalProject || 543 firstInferredProject); 544 } 545 } 546 547 registerFileUpdate(): void { 548 for (const p of this.containingProjects) { 549 p.registerFileUpdate(this.path); 550 } 551 } 552 553 setOptions(formatSettings: FormatCodeSettings, preferences: protocol.UserPreferences | undefined): void { 554 if (formatSettings) { 555 if (!this.formatSettings) { 556 this.formatSettings = getDefaultFormatCodeSettings(this.host.newLine); 557 assign(this.formatSettings, formatSettings); 558 } 559 else { 560 this.formatSettings = { ...this.formatSettings, ...formatSettings }; 561 } 562 } 563 564 if (preferences) { 565 if (!this.preferences) { 566 this.preferences = emptyOptions; 567 } 568 this.preferences = { ...this.preferences, ...preferences }; 569 } 570 } 571 572 getLatestVersion(): string { 573 // Ensure we have updated snapshot to give back latest version 574 this.textStorage.getSnapshot(); 575 return this.textStorage.getVersion(); 576 } 577 578 saveTo(fileName: string) { 579 this.host.writeFile(fileName, getSnapshotText(this.textStorage.getSnapshot())); 580 } 581 582 /*@internal*/ 583 delayReloadNonMixedContentFile() { 584 Debug.assert(!this.isDynamicOrHasMixedContent()); 585 this.textStorage.delayReloadFromFileIntoText(); 586 this.markContainingProjectsAsDirty(); 587 } 588 589 reloadFromFile(tempFileName?: NormalizedPath) { 590 if (this.isDynamicOrHasMixedContent()) { 591 this.textStorage.reload(""); 592 this.markContainingProjectsAsDirty(); 593 return true; 594 } 595 else { 596 if (this.textStorage.reloadWithFileText(tempFileName)) { 597 this.markContainingProjectsAsDirty(); 598 return true; 599 } 600 } 601 return false; 602 } 603 604 /*@internal*/ 605 getAbsolutePositionAndLineText(line: number): AbsolutePositionAndLineText { 606 return this.textStorage.getAbsolutePositionAndLineText(line); 607 } 608 609 editContent(start: number, end: number, newText: string): void { 610 this.textStorage.edit(start, end, newText); 611 this.markContainingProjectsAsDirty(); 612 } 613 614 markContainingProjectsAsDirty() { 615 for (const p of this.containingProjects) { 616 p.markFileAsDirty(this.path); 617 } 618 } 619 620 isOrphan() { 621 return !forEach(this.containingProjects, p => !p.isOrphan()); 622 } 623 624 /*@internal*/ 625 isContainedByBackgroundProject() { 626 return some( 627 this.containingProjects, 628 p => p.projectKind === ProjectKind.AutoImportProvider || p.projectKind === ProjectKind.Auxiliary); 629 } 630 631 /** 632 * @param line 1 based index 633 */ 634 lineToTextSpan(line: number) { 635 return this.textStorage.lineToTextSpan(line); 636 } 637 638 /** 639 * @param line 1 based index 640 * @param offset 1 based index 641 */ 642 lineOffsetToPosition(line: number, offset: number): number; 643 /*@internal*/ 644 lineOffsetToPosition(line: number, offset: number, allowEdits?: true): number; // eslint-disable-line @typescript-eslint/unified-signatures 645 lineOffsetToPosition(line: number, offset: number, allowEdits?: true): number { 646 return this.textStorage.lineOffsetToPosition(line, offset, allowEdits); 647 } 648 649 positionToLineOffset(position: number): protocol.Location { 650 failIfInvalidPosition(position); 651 const location = this.textStorage.positionToLineOffset(position); 652 failIfInvalidLocation(location); 653 return location; 654 } 655 656 public isJavaScript() { 657 return this.scriptKind === ScriptKind.JS || this.scriptKind === ScriptKind.JSX; 658 } 659 660 /*@internal*/ 661 getLineInfo(): LineInfo { 662 return this.textStorage.getLineInfo(); 663 } 664 665 /*@internal*/ 666 closeSourceMapFileWatcher() { 667 if (this.sourceMapFilePath && !isString(this.sourceMapFilePath)) { 668 closeFileWatcherOf(this.sourceMapFilePath); 669 this.sourceMapFilePath = undefined; 670 } 671 } 672 } 673 674 /** 675 * Throws an error if `project` is an AutoImportProvider or AuxiliaryProject, 676 * which are used in the background by other Projects and should never be 677 * reported as the default project for a ScriptInfo. 678 */ 679 function ensurePrimaryProjectKind(project: Project | undefined) { 680 if (!project || project.projectKind === ProjectKind.AutoImportProvider || project.projectKind === ProjectKind.Auxiliary) { 681 return Errors.ThrowNoProject(); 682 } 683 return project; 684 } 685 686 function failIfInvalidPosition(position: number) { 687 Debug.assert(typeof position === "number", `Expected position ${position} to be a number.`); 688 Debug.assert(position >= 0, `Expected position to be non-negative.`); 689 } 690 691 function failIfInvalidLocation(location: protocol.Location) { 692 Debug.assert(typeof location.line === "number", `Expected line ${location.line} to be a number.`); 693 Debug.assert(typeof location.offset === "number", `Expected offset ${location.offset} to be a number.`); 694 695 Debug.assert(location.line > 0, `Expected line to be non-${location.line === 0 ? "zero" : "negative"}`); 696 Debug.assert(location.offset > 0, `Expected offset to be non-${location.offset === 0 ? "zero" : "negative"}`); 697 } 698} 699