• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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