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 its 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: DocumentRegistryBucketKey; 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 getFormatCodeSettings(): FormatCodeSettings | undefined { return this.formatSettings; } 419 getPreferences(): protocol.UserPreferences | undefined { return this.preferences; } 420 421 attachToProject(project: Project): boolean { 422 const isNew = !this.isAttached(project); 423 if (isNew) { 424 this.containingProjects.push(project); 425 project.onFileAddedOrRemoved(); 426 if (!project.getCompilerOptions().preserveSymlinks) { 427 this.ensureRealPath(); 428 } 429 } 430 return isNew; 431 } 432 433 isAttached(project: Project) { 434 // unrolled for common cases 435 switch (this.containingProjects.length) { 436 case 0: return false; 437 case 1: return this.containingProjects[0] === project; 438 case 2: return this.containingProjects[0] === project || this.containingProjects[1] === project; 439 default: return contains(this.containingProjects, project); 440 } 441 } 442 443 detachFromProject(project: Project) { 444 // unrolled for common cases 445 switch (this.containingProjects.length) { 446 case 0: 447 return; 448 case 1: 449 if (this.containingProjects[0] === project) { 450 project.onFileAddedOrRemoved(); 451 this.containingProjects.pop(); 452 } 453 break; 454 case 2: 455 if (this.containingProjects[0] === project) { 456 project.onFileAddedOrRemoved(); 457 this.containingProjects[0] = this.containingProjects.pop()!; 458 } 459 else if (this.containingProjects[1] === project) { 460 project.onFileAddedOrRemoved(); 461 this.containingProjects.pop(); 462 } 463 break; 464 default: 465 if (unorderedRemoveItem(this.containingProjects, project)) { 466 project.onFileAddedOrRemoved(); 467 } 468 break; 469 } 470 } 471 472 detachAllProjects() { 473 for (const p of this.containingProjects) { 474 if (isConfiguredProject(p)) { 475 p.getCachedDirectoryStructureHost().addOrDeleteFile(this.fileName, this.path, FileWatcherEventKind.Deleted); 476 } 477 const existingRoot = p.getRootFilesMap().get(this.path); 478 // detach is unnecessary since we'll clean the list of containing projects anyways 479 p.removeFile(this, /*fileExists*/ false, /*detachFromProjects*/ false); 480 // If the info was for the external or configured project's root, 481 // add missing file as the root 482 if (existingRoot && !isInferredProject(p)) { 483 p.addMissingFileRoot(existingRoot.fileName); 484 } 485 } 486 clear(this.containingProjects); 487 } 488 489 getDefaultProject() { 490 switch (this.containingProjects.length) { 491 case 0: 492 return Errors.ThrowNoProject(); 493 case 1: 494 return ensureNotAutoImportProvider(this.containingProjects[0]); 495 default: 496 // If this file belongs to multiple projects, below is the order in which default project is used 497 // - for open script info, its default configured project during opening is default if info is part of it 498 // - first configured project of which script info is not a source of project reference redirect 499 // - first configured project 500 // - first external project 501 // - first inferred project 502 let firstExternalProject: ExternalProject | undefined; 503 let firstConfiguredProject: ConfiguredProject | undefined; 504 let firstInferredProject: InferredProject | undefined; 505 let firstNonSourceOfProjectReferenceRedirect: ConfiguredProject | undefined; 506 let defaultConfiguredProject: ConfiguredProject | false | undefined; 507 for (let index = 0; index < this.containingProjects.length; index++) { 508 const project = this.containingProjects[index]; 509 if (isConfiguredProject(project)) { 510 if (!project.isSourceOfProjectReferenceRedirect(this.fileName)) { 511 // If we havent found default configuredProject and 512 // its not the last one, find it and use that one if there 513 if (defaultConfiguredProject === undefined && 514 index !== this.containingProjects.length - 1) { 515 defaultConfiguredProject = project.projectService.findDefaultConfiguredProject(this) || false; 516 } 517 if (defaultConfiguredProject === project) return project; 518 if (!firstNonSourceOfProjectReferenceRedirect) firstNonSourceOfProjectReferenceRedirect = project; 519 } 520 if (!firstConfiguredProject) firstConfiguredProject = project; 521 } 522 else if (!firstExternalProject && isExternalProject(project)) { 523 firstExternalProject = project; 524 } 525 else if (!firstInferredProject && isInferredProject(project)) { 526 firstInferredProject = project; 527 } 528 } 529 return ensureNotAutoImportProvider(defaultConfiguredProject || 530 firstNonSourceOfProjectReferenceRedirect || 531 firstConfiguredProject || 532 firstExternalProject || 533 firstInferredProject); 534 } 535 } 536 537 registerFileUpdate(): void { 538 for (const p of this.containingProjects) { 539 p.registerFileUpdate(this.path); 540 } 541 } 542 543 setOptions(formatSettings: FormatCodeSettings, preferences: protocol.UserPreferences | undefined): void { 544 if (formatSettings) { 545 if (!this.formatSettings) { 546 this.formatSettings = getDefaultFormatCodeSettings(this.host.newLine); 547 assign(this.formatSettings, formatSettings); 548 } 549 else { 550 this.formatSettings = { ...this.formatSettings, ...formatSettings }; 551 } 552 } 553 554 if (preferences) { 555 if (!this.preferences) { 556 this.preferences = emptyOptions; 557 } 558 this.preferences = { ...this.preferences, ...preferences }; 559 } 560 } 561 562 getLatestVersion(): string { 563 // Ensure we have updated snapshot to give back latest version 564 this.textStorage.getSnapshot(); 565 return this.textStorage.getVersion(); 566 } 567 568 saveTo(fileName: string) { 569 this.host.writeFile(fileName, getSnapshotText(this.textStorage.getSnapshot())); 570 } 571 572 /*@internal*/ 573 delayReloadNonMixedContentFile() { 574 Debug.assert(!this.isDynamicOrHasMixedContent()); 575 this.textStorage.delayReloadFromFileIntoText(); 576 this.markContainingProjectsAsDirty(); 577 } 578 579 reloadFromFile(tempFileName?: NormalizedPath) { 580 if (this.isDynamicOrHasMixedContent()) { 581 this.textStorage.reload(""); 582 this.markContainingProjectsAsDirty(); 583 return true; 584 } 585 else { 586 if (this.textStorage.reloadWithFileText(tempFileName)) { 587 this.markContainingProjectsAsDirty(); 588 return true; 589 } 590 } 591 return false; 592 } 593 594 /*@internal*/ 595 getAbsolutePositionAndLineText(line: number): AbsolutePositionAndLineText { 596 return this.textStorage.getAbsolutePositionAndLineText(line); 597 } 598 599 editContent(start: number, end: number, newText: string): void { 600 this.textStorage.edit(start, end, newText); 601 this.markContainingProjectsAsDirty(); 602 } 603 604 markContainingProjectsAsDirty() { 605 for (const p of this.containingProjects) { 606 p.markFileAsDirty(this.path); 607 } 608 } 609 610 isOrphan() { 611 return !forEach(this.containingProjects, p => !p.isOrphan()); 612 } 613 614 /*@internal*/ 615 isContainedByAutoImportProvider() { 616 return some(this.containingProjects, p => p.projectKind === ProjectKind.AutoImportProvider); 617 } 618 619 /** 620 * @param line 1 based index 621 */ 622 lineToTextSpan(line: number) { 623 return this.textStorage.lineToTextSpan(line); 624 } 625 626 /** 627 * @param line 1 based index 628 * @param offset 1 based index 629 */ 630 lineOffsetToPosition(line: number, offset: number): number; 631 /*@internal*/ 632 lineOffsetToPosition(line: number, offset: number, allowEdits?: true): number; // eslint-disable-line @typescript-eslint/unified-signatures 633 lineOffsetToPosition(line: number, offset: number, allowEdits?: true): number { 634 return this.textStorage.lineOffsetToPosition(line, offset, allowEdits); 635 } 636 637 positionToLineOffset(position: number): protocol.Location { 638 failIfInvalidPosition(position); 639 const location = this.textStorage.positionToLineOffset(position); 640 failIfInvalidLocation(location); 641 return location; 642 } 643 644 public isJavaScript() { 645 return this.scriptKind === ScriptKind.JS || this.scriptKind === ScriptKind.JSX; 646 } 647 648 /*@internal*/ 649 getLineInfo(): LineInfo { 650 return this.textStorage.getLineInfo(); 651 } 652 653 /*@internal*/ 654 closeSourceMapFileWatcher() { 655 if (this.sourceMapFilePath && !isString(this.sourceMapFilePath)) { 656 closeFileWatcherOf(this.sourceMapFilePath); 657 this.sourceMapFilePath = undefined; 658 } 659 } 660 } 661 662 function ensureNotAutoImportProvider(project: Project | undefined) { 663 if (!project || project.projectKind === ProjectKind.AutoImportProvider) { 664 return Errors.ThrowNoProject(); 665 } 666 return project; 667 } 668 669 function failIfInvalidPosition(position: number) { 670 Debug.assert(typeof position === "number", `Expected position ${position} to be a number.`); 671 Debug.assert(position >= 0, `Expected position to be non-negative.`); 672 } 673 674 function failIfInvalidLocation(location: protocol.Location) { 675 Debug.assert(typeof location.line === "number", `Expected line ${location.line} to be a number.`); 676 Debug.assert(typeof location.offset === "number", `Expected offset ${location.offset} to be a number.`); 677 678 Debug.assert(location.line > 0, `Expected line to be non-${location.line === 0 ? "zero" : "negative"}`); 679 Debug.assert(location.offset > 0, `Expected offset to be non-${location.offset === 0 ? "zero" : "negative"}`); 680 } 681} 682