vscode-apollo 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.circleci/config.yml +1 -1
- package/.vscode/launch.json +4 -1
- package/CHANGELOG.md +33 -0
- package/package.json +9 -3
- package/renovate.json +2 -1
- package/sampleWorkspace/localSchema/src/test.js +3 -0
- package/sampleWorkspace/rover/apollo.config.js +3 -0
- package/sampleWorkspace/rover/src/test.graphql +14 -0
- package/sampleWorkspace/rover/src/test.js +30 -0
- package/sampleWorkspace/sampleWorkspace.code-workspace +25 -19
- package/src/language-server/__tests__/document.test.ts +161 -3
- package/src/language-server/__tests__/fixtures/TypeScript.tmLanguage.json +5749 -0
- package/src/language-server/__tests__/fixtures/documents/commentWithTemplate.ts +41 -0
- package/src/language-server/__tests__/fixtures/documents/commentWithTemplate.ts.snap +185 -0
- package/src/language-server/__tests__/fixtures/documents/functionCall.ts +93 -0
- package/src/language-server/__tests__/fixtures/documents/functionCall.ts.snap +431 -0
- package/src/language-server/__tests__/fixtures/documents/taggedTemplate.ts +80 -0
- package/src/language-server/__tests__/fixtures/documents/taggedTemplate.ts.snap +353 -0
- package/src/language-server/__tests__/fixtures/documents/templateWithComment.ts +38 -0
- package/src/language-server/__tests__/fixtures/documents/templateWithComment.ts.snap +123 -0
- package/src/language-server/config/__tests__/loadConfig.ts +43 -10
- package/src/language-server/config/config.ts +26 -1
- package/src/language-server/config/loadConfig.ts +7 -1
- package/src/language-server/config/loadTsConfig.ts +70 -0
- package/src/language-server/config/which.d.ts +19 -0
- package/src/language-server/document.ts +86 -53
- package/src/language-server/fileSet.ts +7 -0
- package/src/language-server/project/base.ts +58 -316
- package/src/language-server/project/client.ts +730 -7
- package/src/language-server/project/internal.ts +349 -0
- package/src/language-server/project/rover/DocumentSynchronization.ts +308 -0
- package/src/language-server/project/rover/__tests__/DocumentSynchronization.test.ts +302 -0
- package/src/language-server/project/rover/project.ts +276 -0
- package/src/language-server/server.ts +129 -62
- package/src/language-server/utilities/__tests__/source.test.ts +162 -0
- package/src/language-server/utilities/source.ts +38 -3
- package/src/language-server/workspace.ts +34 -9
- package/syntaxes/graphql.js.json +18 -21
- package/src/language-server/languageProvider.ts +0 -795
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import path, { extname } from "path";
|
|
2
|
+
import { lstatSync, readFileSync } from "fs";
|
|
3
|
+
import { URI } from "vscode-uri";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
TypeSystemDefinitionNode,
|
|
7
|
+
isTypeSystemDefinitionNode,
|
|
8
|
+
TypeSystemExtensionNode,
|
|
9
|
+
isTypeSystemExtensionNode,
|
|
10
|
+
DefinitionNode,
|
|
11
|
+
GraphQLSchema,
|
|
12
|
+
Kind,
|
|
13
|
+
} from "graphql";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
FileChangeType,
|
|
17
|
+
NotificationHandler,
|
|
18
|
+
Position,
|
|
19
|
+
} from "vscode-languageserver/node";
|
|
20
|
+
import { TextDocument } from "vscode-languageserver-textdocument";
|
|
21
|
+
|
|
22
|
+
import { GraphQLDocument, extractGraphQLDocuments } from "../document";
|
|
23
|
+
|
|
24
|
+
import { ClientConfig, isClientConfig, isLocalServiceConfig } from "../config";
|
|
25
|
+
import {
|
|
26
|
+
schemaProviderFromConfig,
|
|
27
|
+
GraphQLSchemaProvider,
|
|
28
|
+
SchemaResolveConfig,
|
|
29
|
+
} from "../providers/schema";
|
|
30
|
+
import { ApolloEngineClient, ClientIdentity } from "../engine";
|
|
31
|
+
import { GraphQLProject, DocumentUri, GraphQLProjectConfig } from "./base";
|
|
32
|
+
import throttle from "lodash.throttle";
|
|
33
|
+
|
|
34
|
+
const fileAssociations: { [extension: string]: string } = {
|
|
35
|
+
".graphql": "graphql",
|
|
36
|
+
".gql": "graphql",
|
|
37
|
+
".js": "javascript",
|
|
38
|
+
".ts": "typescript",
|
|
39
|
+
".jsx": "javascriptreact",
|
|
40
|
+
".tsx": "typescriptreact",
|
|
41
|
+
".vue": "vue",
|
|
42
|
+
".svelte": "svelte",
|
|
43
|
+
".py": "python",
|
|
44
|
+
".rb": "ruby",
|
|
45
|
+
".dart": "dart",
|
|
46
|
+
".re": "reason",
|
|
47
|
+
".ex": "elixir",
|
|
48
|
+
".exs": "elixir",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface GraphQLInternalProjectConfig extends GraphQLProjectConfig {
|
|
52
|
+
config: ClientConfig;
|
|
53
|
+
clientIdentity: ClientIdentity;
|
|
54
|
+
}
|
|
55
|
+
export abstract class GraphQLInternalProject
|
|
56
|
+
extends GraphQLProject
|
|
57
|
+
implements GraphQLSchemaProvider
|
|
58
|
+
{
|
|
59
|
+
public schemaProvider: GraphQLSchemaProvider;
|
|
60
|
+
protected engineClient?: ApolloEngineClient;
|
|
61
|
+
|
|
62
|
+
private needsValidation = false;
|
|
63
|
+
|
|
64
|
+
protected documentsByFile: Map<DocumentUri, GraphQLDocument[]>;
|
|
65
|
+
|
|
66
|
+
constructor({
|
|
67
|
+
config,
|
|
68
|
+
configFolderURI,
|
|
69
|
+
loadingHandler,
|
|
70
|
+
clientIdentity,
|
|
71
|
+
}: GraphQLInternalProjectConfig) {
|
|
72
|
+
super({ config, configFolderURI, loadingHandler });
|
|
73
|
+
const { includes = [], excludes = [] } = config.client;
|
|
74
|
+
|
|
75
|
+
this.documentsByFile = new Map();
|
|
76
|
+
|
|
77
|
+
this.fileSet.pushIncludes(includes);
|
|
78
|
+
// We do not want to include the local schema file in our list of documents
|
|
79
|
+
this.fileSet.pushExcludes([
|
|
80
|
+
...excludes,
|
|
81
|
+
...this.getRelativeLocalSchemaFilePaths(),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
this.schemaProvider = schemaProviderFromConfig(config, clientIdentity);
|
|
85
|
+
const { engine } = config;
|
|
86
|
+
if (engine.apiKey) {
|
|
87
|
+
this.engineClient = new ApolloEngineClient(
|
|
88
|
+
engine.apiKey!,
|
|
89
|
+
engine.endpoint,
|
|
90
|
+
clientIdentity,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public resolveSchema(config: SchemaResolveConfig): Promise<GraphQLSchema> {
|
|
96
|
+
this.lastLoadDate = +new Date();
|
|
97
|
+
return this.schemaProvider.resolveSchema(config);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public resolveFederatedServiceSDL() {
|
|
101
|
+
return this.schemaProvider.resolveFederatedServiceSDL();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public onSchemaChange(handler: NotificationHandler<GraphQLSchema>) {
|
|
105
|
+
this.lastLoadDate = +new Date();
|
|
106
|
+
return this.schemaProvider.onSchemaChange(handler);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
includesFile(uri: DocumentUri) {
|
|
110
|
+
return this.fileSet.includesFile(uri);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
allIncludedFiles() {
|
|
114
|
+
return this.fileSet.allFiles();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async scanAllIncludedFiles() {
|
|
118
|
+
await this.loadingHandler.handle(
|
|
119
|
+
`Loading queries for ${this.displayName}`,
|
|
120
|
+
(async () => {
|
|
121
|
+
for (const filePath of this.allIncludedFiles()) {
|
|
122
|
+
const uri = URI.file(filePath).toString();
|
|
123
|
+
|
|
124
|
+
// If we already have query documents for this file, that means it was either
|
|
125
|
+
// opened or changed before we got a chance to read it.
|
|
126
|
+
if (this.documentsByFile.has(uri)) continue;
|
|
127
|
+
|
|
128
|
+
this.fileDidChange(uri);
|
|
129
|
+
}
|
|
130
|
+
})(),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
fileDidChange(uri: DocumentUri) {
|
|
135
|
+
const filePath = URI.parse(uri).fsPath;
|
|
136
|
+
const extension = extname(filePath);
|
|
137
|
+
const languageId = fileAssociations[extension];
|
|
138
|
+
|
|
139
|
+
// Don't process files of an unsupported filetype
|
|
140
|
+
if (!languageId) return;
|
|
141
|
+
|
|
142
|
+
// Don't process directories. Directories might be named like files so
|
|
143
|
+
// we have to explicitly check.
|
|
144
|
+
if (!lstatSync(filePath).isFile()) return;
|
|
145
|
+
|
|
146
|
+
const contents = readFileSync(filePath, "utf8");
|
|
147
|
+
const document = TextDocument.create(uri, languageId, -1, contents);
|
|
148
|
+
this.documentDidChange(document);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
fileWasDeleted(uri: DocumentUri) {
|
|
152
|
+
this.removeGraphQLDocumentsFor(uri);
|
|
153
|
+
this.checkForDuplicateOperations();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
documentDidChange = (document: TextDocument) => {
|
|
157
|
+
const documents = extractGraphQLDocuments(
|
|
158
|
+
document,
|
|
159
|
+
this.config.client && this.config.client.tagName,
|
|
160
|
+
);
|
|
161
|
+
if (documents) {
|
|
162
|
+
this.documentsByFile.set(document.uri, documents);
|
|
163
|
+
this.invalidate();
|
|
164
|
+
} else {
|
|
165
|
+
this.removeGraphQLDocumentsFor(document.uri);
|
|
166
|
+
}
|
|
167
|
+
this.checkForDuplicateOperations();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
checkForDuplicateOperations = throttle(
|
|
171
|
+
() => {
|
|
172
|
+
const filePathForOperationName: Record<string, string> = {};
|
|
173
|
+
for (const [
|
|
174
|
+
fileUri,
|
|
175
|
+
documentsForFile,
|
|
176
|
+
] of this.documentsByFile.entries()) {
|
|
177
|
+
const filePath = URI.parse(fileUri).fsPath;
|
|
178
|
+
for (const document of documentsForFile) {
|
|
179
|
+
if (!document.ast) continue;
|
|
180
|
+
for (const definition of document.ast.definitions) {
|
|
181
|
+
if (
|
|
182
|
+
definition.kind === Kind.OPERATION_DEFINITION &&
|
|
183
|
+
definition.name
|
|
184
|
+
) {
|
|
185
|
+
const operationName = definition.name.value;
|
|
186
|
+
if (operationName in filePathForOperationName) {
|
|
187
|
+
const conflictingFilePath =
|
|
188
|
+
filePathForOperationName[operationName];
|
|
189
|
+
throw new Error(
|
|
190
|
+
`️️There are multiple definitions for the \`${definition.name.value}\` operation. Please fix all naming conflicts before continuing.\nConflicting definitions found at ${filePath} and ${conflictingFilePath}.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
filePathForOperationName[operationName] = filePath;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
250,
|
|
200
|
+
{ leading: true, trailing: true },
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
private removeGraphQLDocumentsFor(uri: DocumentUri) {
|
|
204
|
+
if (this.documentsByFile.has(uri)) {
|
|
205
|
+
this.documentsByFile.delete(uri);
|
|
206
|
+
|
|
207
|
+
if (this._onDiagnostics) {
|
|
208
|
+
this._onDiagnostics({ uri: uri, diagnostics: [] });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this.invalidate();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
protected invalidate() {
|
|
216
|
+
if (!this.needsValidation && this.isReady) {
|
|
217
|
+
setTimeout(() => {
|
|
218
|
+
this.validateIfNeeded();
|
|
219
|
+
}, 0);
|
|
220
|
+
this.needsValidation = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private validateIfNeeded() {
|
|
225
|
+
if (!this.needsValidation || !this.isReady) return;
|
|
226
|
+
|
|
227
|
+
this.validate();
|
|
228
|
+
|
|
229
|
+
this.needsValidation = false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private getRelativeLocalSchemaFilePaths(): string[] {
|
|
233
|
+
const serviceConfig =
|
|
234
|
+
isClientConfig(this.config) &&
|
|
235
|
+
typeof this.config.client.service === "object" &&
|
|
236
|
+
isLocalServiceConfig(this.config.client.service)
|
|
237
|
+
? this.config.client.service
|
|
238
|
+
: undefined;
|
|
239
|
+
const localSchemaFile = serviceConfig?.localSchemaFile;
|
|
240
|
+
return (
|
|
241
|
+
localSchemaFile === undefined
|
|
242
|
+
? []
|
|
243
|
+
: Array.isArray(localSchemaFile)
|
|
244
|
+
? localSchemaFile
|
|
245
|
+
: [localSchemaFile]
|
|
246
|
+
).map((filePath) =>
|
|
247
|
+
path.relative(this.rootURI.fsPath, path.join(process.cwd(), filePath)),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
abstract validate(): void;
|
|
252
|
+
|
|
253
|
+
clearAllDiagnostics() {
|
|
254
|
+
if (!this._onDiagnostics) return;
|
|
255
|
+
|
|
256
|
+
for (const uri of this.documentsByFile.keys()) {
|
|
257
|
+
this._onDiagnostics({ uri, diagnostics: [] });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
documentsAt(uri: DocumentUri): GraphQLDocument[] | undefined {
|
|
262
|
+
return this.documentsByFile.get(uri);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
documentAt(
|
|
266
|
+
uri: DocumentUri,
|
|
267
|
+
position: Position,
|
|
268
|
+
): GraphQLDocument | undefined {
|
|
269
|
+
const queryDocuments = this.documentsByFile.get(uri);
|
|
270
|
+
if (!queryDocuments) return undefined;
|
|
271
|
+
|
|
272
|
+
return queryDocuments.find((document) =>
|
|
273
|
+
document.containsPosition(position),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get documents(): GraphQLDocument[] {
|
|
278
|
+
const documents: GraphQLDocument[] = [];
|
|
279
|
+
for (const documentsForFile of this.documentsByFile.values()) {
|
|
280
|
+
documents.push(...documentsForFile);
|
|
281
|
+
}
|
|
282
|
+
return documents;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
get definitions(): DefinitionNode[] {
|
|
286
|
+
const definitions = [];
|
|
287
|
+
|
|
288
|
+
for (const document of this.documents) {
|
|
289
|
+
if (!document.ast) continue;
|
|
290
|
+
|
|
291
|
+
definitions.push(...document.ast.definitions);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return definitions;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
definitionsAt(uri: DocumentUri): DefinitionNode[] {
|
|
298
|
+
const documents = this.documentsAt(uri);
|
|
299
|
+
if (!documents) return [];
|
|
300
|
+
|
|
301
|
+
const definitions = [];
|
|
302
|
+
|
|
303
|
+
for (const document of documents) {
|
|
304
|
+
if (!document.ast) continue;
|
|
305
|
+
|
|
306
|
+
definitions.push(...document.ast.definitions);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return definitions;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
get typeSystemDefinitionsAndExtensions(): (
|
|
313
|
+
| TypeSystemDefinitionNode
|
|
314
|
+
| TypeSystemExtensionNode
|
|
315
|
+
)[] {
|
|
316
|
+
const definitionsAndExtensions = [];
|
|
317
|
+
for (const document of this.documents) {
|
|
318
|
+
if (!document.ast) continue;
|
|
319
|
+
for (const definition of document.ast.definitions) {
|
|
320
|
+
if (
|
|
321
|
+
isTypeSystemDefinitionNode(definition) ||
|
|
322
|
+
isTypeSystemExtensionNode(definition)
|
|
323
|
+
) {
|
|
324
|
+
definitionsAndExtensions.push(definition);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return definitionsAndExtensions;
|
|
329
|
+
}
|
|
330
|
+
onDidOpen: undefined;
|
|
331
|
+
onDidClose: undefined;
|
|
332
|
+
onDidChangeWatchedFiles: GraphQLProject["onDidChangeWatchedFiles"] = (
|
|
333
|
+
params,
|
|
334
|
+
) => {
|
|
335
|
+
for (const { uri, type } of params.changes) {
|
|
336
|
+
switch (type) {
|
|
337
|
+
case FileChangeType.Created:
|
|
338
|
+
this.fileDidChange(uri);
|
|
339
|
+
break;
|
|
340
|
+
case FileChangeType.Deleted:
|
|
341
|
+
this.fileWasDeleted(uri);
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
onUnhandledRequest: undefined;
|
|
347
|
+
onUnhandledNotification: undefined;
|
|
348
|
+
dispose: undefined;
|
|
349
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { extractGraphQLSources } from "../../document";
|
|
2
|
+
import {
|
|
3
|
+
ProtocolNotificationType,
|
|
4
|
+
DidChangeTextDocumentNotification,
|
|
5
|
+
DidOpenTextDocumentNotification,
|
|
6
|
+
DidCloseTextDocumentNotification,
|
|
7
|
+
TextDocumentPositionParams,
|
|
8
|
+
Diagnostic,
|
|
9
|
+
NotificationHandler,
|
|
10
|
+
PublishDiagnosticsParams,
|
|
11
|
+
} from "vscode-languageserver-protocol";
|
|
12
|
+
import { TextDocument } from "vscode-languageserver-textdocument";
|
|
13
|
+
import { DocumentUri, GraphQLProject } from "../base";
|
|
14
|
+
import { generateKeyBetween } from "fractional-indexing";
|
|
15
|
+
import { Source } from "graphql";
|
|
16
|
+
import {
|
|
17
|
+
findContainedSourceAndPosition,
|
|
18
|
+
rangeInContainingDocument,
|
|
19
|
+
} from "../../utilities/source";
|
|
20
|
+
import { URI } from "vscode-uri";
|
|
21
|
+
import { DEBUG } from "./project";
|
|
22
|
+
|
|
23
|
+
export interface FilePart {
|
|
24
|
+
fractionalIndex: string;
|
|
25
|
+
source: Source;
|
|
26
|
+
diagnostics: Diagnostic[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function handleFilePartUpdates(
|
|
30
|
+
parsed: ReadonlyArray<Source>,
|
|
31
|
+
previousParts: ReadonlyArray<FilePart>,
|
|
32
|
+
): ReadonlyArray<FilePart> {
|
|
33
|
+
const newParts: FilePart[] = [];
|
|
34
|
+
let newIdx = 0;
|
|
35
|
+
let oldIdx = 0;
|
|
36
|
+
let offsetCorrection = 0;
|
|
37
|
+
while (newIdx < parsed.length || oldIdx < previousParts.length) {
|
|
38
|
+
const source = parsed[newIdx] as Source | undefined;
|
|
39
|
+
const oldPart = previousParts[oldIdx] as FilePart | undefined;
|
|
40
|
+
if (!source) return newParts;
|
|
41
|
+
const newOffset = source.locationOffset.line;
|
|
42
|
+
|
|
43
|
+
if (
|
|
44
|
+
oldPart &&
|
|
45
|
+
(source.body === oldPart.source.body ||
|
|
46
|
+
newOffset === oldPart.source.locationOffset.line + offsetCorrection)
|
|
47
|
+
) {
|
|
48
|
+
// replacement of chunk
|
|
49
|
+
newParts.push({ ...oldPart, source });
|
|
50
|
+
offsetCorrection =
|
|
51
|
+
source.locationOffset.line - oldPart.source.locationOffset.line;
|
|
52
|
+
newIdx++;
|
|
53
|
+
oldIdx++;
|
|
54
|
+
} else if (
|
|
55
|
+
!oldPart ||
|
|
56
|
+
newOffset < oldPart.source.locationOffset.line + offsetCorrection
|
|
57
|
+
) {
|
|
58
|
+
// inserted chunk
|
|
59
|
+
const fractionalIndex = generateKeyBetween(
|
|
60
|
+
newParts.length == 0
|
|
61
|
+
? null
|
|
62
|
+
: newParts[newParts.length - 1].fractionalIndex,
|
|
63
|
+
oldPart ? oldPart.fractionalIndex : null,
|
|
64
|
+
);
|
|
65
|
+
newParts.push({ source, fractionalIndex, diagnostics: [] });
|
|
66
|
+
newIdx++;
|
|
67
|
+
offsetCorrection += source.body.split("\n").length - 1;
|
|
68
|
+
} else {
|
|
69
|
+
// deleted chunk
|
|
70
|
+
oldIdx++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return newParts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getUri(document: TextDocument, part: FilePart) {
|
|
77
|
+
let uri = URI.parse(part.source.name);
|
|
78
|
+
if (document.languageId !== "graphql") {
|
|
79
|
+
uri = uri.with({ fragment: part.fractionalIndex });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return uri.toString();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function splitUri(fullUri: DocumentUri) {
|
|
86
|
+
const uri = URI.parse(fullUri);
|
|
87
|
+
return {
|
|
88
|
+
uri: uri.with({ fragment: null }).toString(),
|
|
89
|
+
fractionalIndex: uri.fragment || "a0",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class DocumentSynchronization {
|
|
94
|
+
private pendingDocumentChanges = new Map<DocumentUri, TextDocument>();
|
|
95
|
+
private knownFiles = new Map<
|
|
96
|
+
DocumentUri,
|
|
97
|
+
{
|
|
98
|
+
full: TextDocument;
|
|
99
|
+
parts: ReadonlyArray<FilePart>;
|
|
100
|
+
}
|
|
101
|
+
>();
|
|
102
|
+
|
|
103
|
+
constructor(
|
|
104
|
+
private sendNotification: <P, RO>(
|
|
105
|
+
type: ProtocolNotificationType<P, RO>,
|
|
106
|
+
params?: P,
|
|
107
|
+
) => Promise<void>,
|
|
108
|
+
private sendDiagnostics: NotificationHandler<PublishDiagnosticsParams>,
|
|
109
|
+
) {}
|
|
110
|
+
|
|
111
|
+
private documentSynchronizationScheduled = false;
|
|
112
|
+
/**
|
|
113
|
+
* Ensures that only one `syncNextDocumentChange` is queued with the connection at a time.
|
|
114
|
+
* As a result, other, more important, changes can be processed with higher priority.
|
|
115
|
+
*/
|
|
116
|
+
private scheduleDocumentSync = async () => {
|
|
117
|
+
if (
|
|
118
|
+
this.pendingDocumentChanges.size === 0 ||
|
|
119
|
+
this.documentSynchronizationScheduled
|
|
120
|
+
) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.documentSynchronizationScheduled = true;
|
|
125
|
+
try {
|
|
126
|
+
const next = this.pendingDocumentChanges.values().next();
|
|
127
|
+
if (next.done) return;
|
|
128
|
+
await this.sendDocumentChanges(next.value);
|
|
129
|
+
} finally {
|
|
130
|
+
this.documentSynchronizationScheduled = false;
|
|
131
|
+
setImmediate(this.scheduleDocumentSync);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
private async sendDocumentChanges(
|
|
136
|
+
document: TextDocument,
|
|
137
|
+
previousParts = this.knownFiles.get(document.uri)?.parts || [],
|
|
138
|
+
) {
|
|
139
|
+
this.pendingDocumentChanges.delete(document.uri);
|
|
140
|
+
|
|
141
|
+
const previousObj = Object.fromEntries(
|
|
142
|
+
previousParts.map((p) => [p.fractionalIndex, p]),
|
|
143
|
+
);
|
|
144
|
+
const newParts = handleFilePartUpdates(
|
|
145
|
+
extractGraphQLSources(document) || [],
|
|
146
|
+
previousParts,
|
|
147
|
+
);
|
|
148
|
+
const newObj = Object.fromEntries(
|
|
149
|
+
newParts.map((p) => [p.fractionalIndex, p]),
|
|
150
|
+
);
|
|
151
|
+
this.knownFiles.set(document.uri, { full: document, parts: newParts });
|
|
152
|
+
|
|
153
|
+
for (const newPart of newParts) {
|
|
154
|
+
const previousPart = previousObj[newPart.fractionalIndex];
|
|
155
|
+
if (!previousPart) {
|
|
156
|
+
await this.sendNotification(DidOpenTextDocumentNotification.type, {
|
|
157
|
+
textDocument: {
|
|
158
|
+
uri: getUri(document, newPart),
|
|
159
|
+
languageId: "graphql",
|
|
160
|
+
version: document.version,
|
|
161
|
+
text: newPart.source.body,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
} else if (newPart.source.body !== previousPart.source.body) {
|
|
165
|
+
await this.sendNotification(DidChangeTextDocumentNotification.type, {
|
|
166
|
+
textDocument: {
|
|
167
|
+
uri: getUri(document, newPart),
|
|
168
|
+
version: document.version,
|
|
169
|
+
},
|
|
170
|
+
contentChanges: [
|
|
171
|
+
{
|
|
172
|
+
text: newPart.source.body,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const previousPart of previousParts) {
|
|
179
|
+
if (!newObj[previousPart.fractionalIndex]) {
|
|
180
|
+
await this.sendNotification(DidCloseTextDocumentNotification.type, {
|
|
181
|
+
textDocument: {
|
|
182
|
+
uri: getUri(document, previousPart),
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async resendAllDocuments() {
|
|
190
|
+
for (const file of this.knownFiles.values()) {
|
|
191
|
+
await this.sendDocumentChanges(file.full, []);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
onDidOpenTextDocument: NonNullable<GraphQLProject["onDidOpen"]> = async (
|
|
196
|
+
params,
|
|
197
|
+
) => {
|
|
198
|
+
this.documentDidChange(params.document);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
onDidCloseTextDocument: NonNullable<GraphQLProject["onDidClose"]> = (
|
|
202
|
+
params,
|
|
203
|
+
) => {
|
|
204
|
+
const known = this.knownFiles.get(params.document.uri);
|
|
205
|
+
if (!known) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.knownFiles.delete(params.document.uri);
|
|
209
|
+
return Promise.all(
|
|
210
|
+
known.parts.map((part) =>
|
|
211
|
+
this.sendNotification(DidCloseTextDocumentNotification.type, {
|
|
212
|
+
textDocument: {
|
|
213
|
+
uri: getUri(known.full, part),
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
async documentDidChange(document: TextDocument) {
|
|
221
|
+
if (this.pendingDocumentChanges.has(document.uri)) {
|
|
222
|
+
// this will put the document at the end of the queue again
|
|
223
|
+
// in hopes that we can skip a bit of unnecessary work sometimes
|
|
224
|
+
// when many files change around a lot
|
|
225
|
+
// we will always ensure that a document is synchronized via `synchronizedWithDocument`
|
|
226
|
+
// before we do other operations on the document, so this is safe
|
|
227
|
+
this.pendingDocumentChanges.delete(document.uri);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.pendingDocumentChanges.set(document.uri, document);
|
|
231
|
+
this.scheduleDocumentSync();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async synchronizedWithDocument(documentUri: DocumentUri): Promise<void> {
|
|
235
|
+
const document = this.pendingDocumentChanges.get(documentUri);
|
|
236
|
+
if (document) {
|
|
237
|
+
await this.sendDocumentChanges(document);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async insideVirtualDocument<T>(
|
|
242
|
+
positionParams: TextDocumentPositionParams,
|
|
243
|
+
cb: (virtualPositionParams: TextDocumentPositionParams) => Promise<T>,
|
|
244
|
+
): Promise<T | undefined> {
|
|
245
|
+
await this.synchronizedWithDocument(positionParams.textDocument.uri);
|
|
246
|
+
const found = this.knownFiles.get(positionParams.textDocument.uri);
|
|
247
|
+
if (!found) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const match = findContainedSourceAndPosition(
|
|
251
|
+
found.parts,
|
|
252
|
+
positionParams.position,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
if (!match) return;
|
|
256
|
+
return cb({
|
|
257
|
+
textDocument: {
|
|
258
|
+
uri: getUri(found.full, match),
|
|
259
|
+
},
|
|
260
|
+
position: match.position,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
handlePartDiagnostics(params: PublishDiagnosticsParams) {
|
|
265
|
+
DEBUG && console.log("Received diagnostics", params);
|
|
266
|
+
const uriDetails = splitUri(params.uri);
|
|
267
|
+
if (!uriDetails) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const found = this.knownFiles.get(uriDetails.uri);
|
|
271
|
+
if (!found) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const part = found.parts.find(
|
|
275
|
+
(p) => p.fractionalIndex === uriDetails.fractionalIndex,
|
|
276
|
+
);
|
|
277
|
+
if (!part) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
part.diagnostics = params.diagnostics;
|
|
281
|
+
|
|
282
|
+
const fullDocumentParams: PublishDiagnosticsParams = {
|
|
283
|
+
uri: found.full.uri,
|
|
284
|
+
version: found.full.version,
|
|
285
|
+
diagnostics: found.parts.flatMap((p) =>
|
|
286
|
+
p.diagnostics.map((diagnostic) => ({
|
|
287
|
+
...diagnostic,
|
|
288
|
+
range: rangeInContainingDocument(p.source, diagnostic.range),
|
|
289
|
+
})),
|
|
290
|
+
),
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
this.sendDiagnostics(fullDocumentParams);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
get openDocuments() {
|
|
297
|
+
return [...this.knownFiles.values()].map((f) => f.full);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
clearAllDiagnostics() {
|
|
301
|
+
for (const file of this.knownFiles.values()) {
|
|
302
|
+
for (const part of file.parts) {
|
|
303
|
+
part.diagnostics = [];
|
|
304
|
+
}
|
|
305
|
+
this.sendDiagnostics({ uri: file.full.uri, diagnostics: [] });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|