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