Skip to content

Commit 0d9e577

Browse files
authored
(fix) normalize paths, deduplicate snapshots (#1040)
- normalize paths, as it's not guaranteed that they are in the same format coming from different sources - deduplicate snapshots by introducing a global snapshot manager. A snapshot is unique across all services. If a snapshot is updated, that update should be reflected in all services #1037
1 parent 45537ad commit 0d9e577

File tree

13 files changed

+283
-96
lines changed

13 files changed

+283
-96
lines changed

packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import ts from 'typescript';
2+
import { TextDocumentContentChangeEvent } from 'vscode-languageserver';
23
import { Document, DocumentManager } from '../../lib/documents';
34
import { LSConfigManager } from '../../ls-config';
4-
import { debounceSameArg, pathToUrl } from '../../utils';
5+
import { debounceSameArg, normalizePath, pathToUrl } from '../../utils';
56
import { DocumentSnapshot, SvelteDocumentSnapshot } from './DocumentSnapshot';
67
import {
78
getService,
89
getServiceForTsconfig,
9-
hasServiceForFile,
10+
forAllServices,
1011
LanguageServiceContainer,
1112
LanguageServiceDocumentContext
1213
} from './service';
13-
import { SnapshotManager } from './SnapshotManager';
14+
import { GlobalSnapshotsManager, SnapshotManager } from './SnapshotManager';
1415

1516
export class LSAndTSDocResolver {
1617
/**
@@ -59,10 +60,13 @@ export class LSAndTSDocResolver {
5960
return document;
6061
};
6162

63+
private globalSnapshotsManager = new GlobalSnapshotsManager();
64+
6265
private get lsDocumentContext(): LanguageServiceDocumentContext {
6366
return {
6467
createDocument: this.createDocument,
65-
transformOnTemplateError: !this.tsconfigPath
68+
transformOnTemplateError: !this.tsconfigPath,
69+
globalSnapshotsManager: this.globalSnapshotsManager
6670
};
6771
}
6872

@@ -82,6 +86,11 @@ export class LSAndTSDocResolver {
8286
return { tsDoc, lang, userPreferences };
8387
}
8488

89+
/**
90+
* Retrieves and updates the snapshot for the given document or path from
91+
* the ts service it primarely belongs into.
92+
* The update is mirrored in all other services, too.
93+
*/
8594
async getSnapshot(document: Document): Promise<SvelteDocumentSnapshot>;
8695
async getSnapshot(pathOrDoc: string | Document): Promise<DocumentSnapshot>;
8796
async getSnapshot(pathOrDoc: string | Document) {
@@ -90,21 +99,49 @@ export class LSAndTSDocResolver {
9099
return tsService.updateSnapshot(pathOrDoc);
91100
}
92101

102+
/**
103+
* Updates snapshot path in all existing ts services and retrieves snapshot
104+
*/
93105
async updateSnapshotPath(oldPath: string, newPath: string): Promise<DocumentSnapshot> {
94106
await this.deleteSnapshot(oldPath);
95107
return this.getSnapshot(newPath);
96108
}
97109

110+
/**
111+
* Deletes snapshot in all existing ts services
112+
*/
98113
async deleteSnapshot(filePath: string) {
99-
if (!hasServiceForFile(filePath, this.workspaceUris)) {
100-
// Don't initialize a service for a file that should be deleted
101-
return;
102-
}
103-
104-
(await this.getTSService(filePath)).deleteSnapshot(filePath);
114+
await forAllServices((service) => service.deleteSnapshot(filePath));
105115
this.docManager.releaseDocument(pathToUrl(filePath));
106116
}
107117

118+
/**
119+
* Updates project files in all existing ts services
120+
*/
121+
async updateProjectFiles() {
122+
await forAllServices((service) => service.updateProjectFiles());
123+
}
124+
125+
/**
126+
* Updates file in all ts services where it exists
127+
*/
128+
async updateExistingTsOrJsFile(
129+
path: string,
130+
changes?: TextDocumentContentChangeEvent[]
131+
): Promise<void> {
132+
path = normalizePath(path);
133+
// Only update once because all snapshots are shared between
134+
// services. Since we don't have a current version of TS/JS
135+
// files, the operation wouldn't be idempotent.
136+
let didUpdate = false;
137+
await forAllServices((service) => {
138+
if (service.hasFile(path) && !didUpdate) {
139+
didUpdate = true;
140+
service.updateTsOrJsFile(path, changes);
141+
}
142+
});
143+
}
144+
108145
/**
109146
* @internal Public for tests only
110147
*/

packages/language-server/src/plugins/typescript/SnapshotManager.ts

Lines changed: 113 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,72 @@ import ts from 'typescript';
22
import { DocumentSnapshot, JSOrTSDocumentSnapshot } from './DocumentSnapshot';
33
import { Logger } from '../../logger';
44
import { TextDocumentContentChangeEvent } from 'vscode-languageserver';
5+
import { normalizePath } from '../../utils';
6+
import { EventEmitter } from 'events';
7+
8+
/**
9+
* Every snapshot corresponds to a unique file on disk.
10+
* A snapshot can be part of multiple projects, but for a given file path
11+
* there can be only one snapshot.
12+
*/
13+
export class GlobalSnapshotsManager {
14+
private emitter = new EventEmitter();
15+
private documents = new Map<string, DocumentSnapshot>();
16+
17+
get(fileName: string) {
18+
fileName = normalizePath(fileName);
19+
return this.documents.get(fileName);
20+
}
21+
22+
set(fileName: string, document: DocumentSnapshot) {
23+
fileName = normalizePath(fileName);
24+
const prev = this.get(fileName);
25+
if (prev) {
26+
prev.destroyFragment();
27+
}
28+
29+
this.documents.set(fileName, document);
30+
this.emitter.emit('change', fileName, document);
31+
}
32+
33+
delete(fileName: string) {
34+
fileName = normalizePath(fileName);
35+
this.documents.delete(fileName);
36+
this.emitter.emit('change', fileName, undefined);
37+
}
38+
39+
updateTsOrJsFile(
40+
fileName: string,
41+
changes?: TextDocumentContentChangeEvent[]
42+
): JSOrTSDocumentSnapshot | undefined {
43+
fileName = normalizePath(fileName);
44+
const previousSnapshot = this.get(fileName);
45+
46+
if (changes) {
47+
if (!(previousSnapshot instanceof JSOrTSDocumentSnapshot)) {
48+
return;
49+
}
50+
previousSnapshot.update(changes);
51+
return previousSnapshot;
52+
} else {
53+
const newSnapshot = DocumentSnapshot.fromNonSvelteFilePath(fileName);
54+
55+
if (previousSnapshot) {
56+
newSnapshot.version = previousSnapshot.version + 1;
57+
} else {
58+
// ensure it's greater than initial version
59+
// so that ts server picks up the change
60+
newSnapshot.version += 1;
61+
}
62+
this.set(fileName, newSnapshot);
63+
return newSnapshot;
64+
}
65+
}
66+
67+
onChange(listener: (fileName: string, newDocument: DocumentSnapshot | undefined) => void) {
68+
this.emitter.on('change', listener);
69+
}
70+
}
571

672
export interface TsFilesSpec {
773
include?: readonly string[];
@@ -12,7 +78,7 @@ export interface TsFilesSpec {
1278
* Should only be used by `service.ts`
1379
*/
1480
export class SnapshotManager {
15-
private documents: Map<string, DocumentSnapshot> = new Map();
81+
private documents = new Map<string, DocumentSnapshot>();
1682
private lastLogged = new Date(new Date().getTime() - 60_001);
1783

1884
private readonly watchExtensions = [
@@ -25,12 +91,26 @@ export class SnapshotManager {
2591
];
2692

2793
constructor(
94+
private globalSnapshotsManager: GlobalSnapshotsManager,
2895
private projectFiles: string[],
2996
private fileSpec: TsFilesSpec,
3097
private workspaceRoot: string
31-
) {}
98+
) {
99+
this.globalSnapshotsManager.onChange((fileName, document) => {
100+
// Only delete/update snapshots, don't add new ones,
101+
// as they could be from another TS service and this
102+
// snapshot manager can't reach this file.
103+
// For these, instead wait on a `get` method invocation
104+
// and set them "manually" in the set/update methods.
105+
if (!document) {
106+
this.documents.delete(fileName);
107+
} else if (this.documents.has(fileName)) {
108+
this.documents.set(fileName, document);
109+
}
110+
});
111+
}
32112

33-
updateProjectFiles() {
113+
updateProjectFiles(): void {
34114
const { include, exclude } = this.fileSpec;
35115

36116
// Since we default to not include anything,
@@ -39,67 +119,58 @@ export class SnapshotManager {
39119
return;
40120
}
41121

42-
const projectFiles = ts.sys.readDirectory(
43-
this.workspaceRoot,
44-
this.watchExtensions,
45-
exclude,
46-
include
47-
);
122+
const projectFiles = ts.sys
123+
.readDirectory(this.workspaceRoot, this.watchExtensions, exclude, include)
124+
.map(normalizePath);
48125

49126
this.projectFiles = Array.from(new Set([...this.projectFiles, ...projectFiles]));
50127
}
51128

52129
updateTsOrJsFile(fileName: string, changes?: TextDocumentContentChangeEvent[]): void {
53-
const previousSnapshot = this.get(fileName);
54-
55-
if (changes) {
56-
if (!(previousSnapshot instanceof JSOrTSDocumentSnapshot)) {
57-
return;
58-
}
59-
previousSnapshot.update(changes);
60-
} else {
61-
const newSnapshot = DocumentSnapshot.fromNonSvelteFilePath(fileName);
62-
63-
if (previousSnapshot) {
64-
newSnapshot.version = previousSnapshot.version + 1;
65-
} else {
66-
// ensure it's greater than initial version
67-
// so that ts server picks up the change
68-
newSnapshot.version += 1;
69-
}
70-
this.set(fileName, newSnapshot);
130+
const snapshot = this.globalSnapshotsManager.updateTsOrJsFile(fileName, changes);
131+
// This isn't duplicated logic to the listener, because this could
132+
// be a new snapshot which the listener wouldn't add.
133+
if (snapshot) {
134+
this.documents.set(normalizePath(fileName), snapshot);
71135
}
72136
}
73137

74-
has(fileName: string) {
138+
has(fileName: string): boolean {
139+
fileName = normalizePath(fileName);
75140
return this.projectFiles.includes(fileName) || this.getFileNames().includes(fileName);
76141
}
77142

78-
set(fileName: string, snapshot: DocumentSnapshot) {
79-
const prev = this.get(fileName);
80-
if (prev) {
81-
prev.destroyFragment();
82-
}
83-
143+
set(fileName: string, snapshot: DocumentSnapshot): void {
144+
this.globalSnapshotsManager.set(fileName, snapshot);
145+
// This isn't duplicated logic to the listener, because this could
146+
// be a new snapshot which the listener wouldn't add.
147+
this.documents.set(normalizePath(fileName), snapshot);
84148
this.logStatistics();
85-
86-
return this.documents.set(fileName, snapshot);
87149
}
88150

89-
get(fileName: string) {
90-
return this.documents.get(fileName);
151+
get(fileName: string): DocumentSnapshot | undefined {
152+
fileName = normalizePath(fileName);
153+
let snapshot = this.documents.get(fileName);
154+
if (!snapshot) {
155+
snapshot = this.globalSnapshotsManager.get(fileName);
156+
if (snapshot) {
157+
this.documents.set(fileName, snapshot);
158+
}
159+
}
160+
return snapshot;
91161
}
92162

93-
delete(fileName: string) {
163+
delete(fileName: string): void {
164+
fileName = normalizePath(fileName);
94165
this.projectFiles = this.projectFiles.filter((s) => s !== fileName);
95-
return this.documents.delete(fileName);
166+
this.globalSnapshotsManager.delete(fileName);
96167
}
97168

98-
getFileNames() {
169+
getFileNames(): string[] {
99170
return Array.from(this.documents.keys());
100171
}
101172

102-
getProjectFileNames() {
173+
getProjectFileNames(): string[] {
103174
return [...this.projectFiles];
104175
}
105176

packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ import { SignatureHelpProviderImpl } from './features/SignatureHelpProvider';
6161
import { UpdateImportsProviderImpl } from './features/UpdateImportsProvider';
6262
import { isNoTextSpanInGeneratedCode, SnapshotFragmentMap } from './features/utils';
6363
import { LSAndTSDocResolver } from './LSAndTSDocResolver';
64-
import { LanguageServiceContainer } from './service';
6564
import { ignoredBuildDirectories } from './SnapshotManager';
6665
import { convertToLocationRange, getScriptKindFromFileName, symbolKindFromString } from './utils';
6766

@@ -371,7 +370,7 @@ export class TypeScriptPlugin
371370
}
372371

373372
async onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): Promise<void> {
374-
const doneUpdateProjectFiles = new Set<LanguageServiceContainer>();
373+
let doneUpdateProjectFiles = false;
375374

376375
for (const { fileName, changeType } of onWatchFileChangesParas) {
377376
const pathParts = fileName.split(/\/|\\/);
@@ -386,19 +385,13 @@ export class TypeScriptPlugin
386385
continue;
387386
}
388387

389-
const tsService = await this.lsAndTsDocResolver.getTSService(fileName);
390-
if (changeType === FileChangeType.Created) {
391-
if (!doneUpdateProjectFiles.has(tsService)) {
392-
tsService.updateProjectFiles();
393-
doneUpdateProjectFiles.add(tsService);
394-
}
388+
if (changeType === FileChangeType.Created && !doneUpdateProjectFiles) {
389+
doneUpdateProjectFiles = true;
390+
await this.lsAndTsDocResolver.updateProjectFiles();
395391
} else if (changeType === FileChangeType.Deleted) {
396-
tsService.deleteSnapshot(fileName);
397-
} else if (tsService.hasFile(fileName)) {
398-
// Only allow existing files to be update
399-
// Otherwise, new files would still get loaded
400-
// into snapshot manager after update
401-
tsService.updateTsOrJsFile(fileName);
392+
await this.lsAndTsDocResolver.deleteSnapshot(fileName);
393+
} else {
394+
await this.lsAndTsDocResolver.updateExistingTsOrJsFile(fileName);
402395
}
403396
}
404397
}
@@ -407,8 +400,7 @@ export class TypeScriptPlugin
407400
fileName: string,
408401
changes: TextDocumentContentChangeEvent[]
409402
): Promise<void> {
410-
const snapshotManager = await this.getSnapshotManager(fileName);
411-
snapshotManager.updateTsOrJsFile(fileName, changes);
403+
await this.lsAndTsDocResolver.updateExistingTsOrJsFile(fileName, changes);
412404
}
413405

414406
async getSelectionRange(

0 commit comments

Comments
 (0)