• 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 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