UNPKG

23.6 kBPlain TextView Raw
1import {
2 CancellationToken,
3 Position,
4 Location,
5 Range,
6 CompletionItem,
7 Hover,
8 Definition,
9 CodeLens,
10 ReferenceContext,
11 InsertTextFormat,
12 DocumentSymbol,
13 SymbolKind,
14 SymbolInformation,
15 CodeAction,
16 CodeActionKind,
17 MarkupKind,
18 CompletionItemKind,
19} from "vscode-languageserver";
20
21// should eventually be moved into this package, since we're overriding a lot of the existing behavior here
22import { getAutocompleteSuggestions } from "@apollographql/graphql-language-service-interface";
23import {
24 getTokenAtPosition,
25 getTypeInfo,
26} from "@apollographql/graphql-language-service-interface/dist/getAutocompleteSuggestions";
27import { GraphQLWorkspace } from "./workspace";
28import { DocumentUri } from "./project/base";
29
30import {
31 positionFromPositionInContainingDocument,
32 rangeForASTNode,
33 getASTNodeAndTypeInfoAtPosition,
34 positionToOffset,
35} from "./utilities/source";
36
37import {
38 GraphQLNamedType,
39 Kind,
40 GraphQLField,
41 GraphQLNonNull,
42 isAbstractType,
43 TypeNameMetaFieldDef,
44 SchemaMetaFieldDef,
45 TypeMetaFieldDef,
46 typeFromAST,
47 GraphQLType,
48 isObjectType,
49 isListType,
50 GraphQLList,
51 isNonNullType,
52 ASTNode,
53 FieldDefinitionNode,
54 visit,
55 isExecutableDefinitionNode,
56 isTypeSystemDefinitionNode,
57 isTypeSystemExtensionNode,
58 GraphQLError,
59 DirectiveLocation,
60} from "graphql";
61import { highlightNodeForNode } from "./utilities/graphql";
62
63import { GraphQLClientProject, isClientProject } from "./project/client";
64import { isNotNullOrUndefined } from "@apollographql/apollo-tools";
65import { CodeActionInfo } from "./errors/validation";
66import { GraphQLDiagnostic } from "./diagnostics";
67
68const DirectiveLocations = Object.keys(DirectiveLocation);
69
70function hasFields(type: GraphQLType): boolean {
71 return (
72 isObjectType(type) ||
73 (isListType(type) && hasFields((type as GraphQLList<any>).ofType)) ||
74 (isNonNullType(type) && hasFields((type as GraphQLNonNull<any>).ofType))
75 );
76}
77
78function uriForASTNode(node: ASTNode): DocumentUri | null {
79 const uri = node.loc && node.loc.source && node.loc.source.name;
80 if (!uri || uri === "GraphQL") {
81 return null;
82 }
83 return uri;
84}
85
86function locationForASTNode(node: ASTNode): Location | null {
87 const uri = uriForASTNode(node);
88 if (!uri) return null;
89 return Location.create(uri, rangeForASTNode(node));
90}
91
92function symbolForFieldDefinition(
93 definition: FieldDefinitionNode
94): DocumentSymbol {
95 return {
96 name: definition.name.value,
97 kind: SymbolKind.Field,
98 range: rangeForASTNode(definition),
99 selectionRange: rangeForASTNode(definition),
100 };
101}
102
103export class GraphQLLanguageProvider {
104 constructor(public workspace: GraphQLWorkspace) {}
105
106 async provideStats(uri?: DocumentUri) {
107 if (this.workspace.projects.length && uri) {
108 const project = this.workspace.projectForFile(uri);
109 return project ? project.getProjectStats() : { loaded: false };
110 }
111
112 return { loaded: false };
113 }
114
115 async provideCompletionItems(
116 uri: DocumentUri,
117 position: Position,
118 _token: CancellationToken
119 ): Promise<CompletionItem[]> {
120 const project = this.workspace.projectForFile(uri);
121 if (!(project && project instanceof GraphQLClientProject)) return [];
122
123 const document = project.documentAt(uri, position);
124 if (!document) return [];
125
126 if (!project.schema) return [];
127
128 const positionInDocument = positionFromPositionInContainingDocument(
129 document.source,
130 position
131 );
132 const token = getTokenAtPosition(document.source.body, positionInDocument);
133 const state =
134 token.state.kind === "Invalid" ? token.state.prevState : token.state;
135 const typeInfo = getTypeInfo(project.schema, token.state);
136
137 if (state.kind === "DirectiveLocation") {
138 return DirectiveLocations.map((location) => ({
139 label: location,
140 kind: CompletionItemKind.Constant,
141 }));
142 }
143
144 const suggestions = getAutocompleteSuggestions(
145 project.schema,
146 document.source.body,
147 positionInDocument
148 );
149
150 if (
151 state.kind === "SelectionSet" ||
152 state.kind === "Field" ||
153 state.kind === "AliasedField"
154 ) {
155 const parentType = typeInfo.parentType;
156 const parentFields = {
157 ...(parentType.getFields() as {
158 [label: string]: GraphQLField<any, any>;
159 }),
160 };
161
162 if (isAbstractType(parentType)) {
163 parentFields[TypeNameMetaFieldDef.name] = TypeNameMetaFieldDef;
164 }
165
166 if (parentType === project.schema.getQueryType()) {
167 parentFields[SchemaMetaFieldDef.name] = SchemaMetaFieldDef;
168 parentFields[TypeMetaFieldDef.name] = TypeMetaFieldDef;
169 }
170
171 return suggestions.map((suggest) => {
172 // when code completing fields, expand out required variables and open braces
173 const suggestedField = parentFields[suggest.label] as GraphQLField<
174 void,
175 void
176 >;
177 if (!suggestedField) {
178 return suggest;
179 } else {
180 const requiredArgs = suggestedField.args.filter((a) =>
181 isNonNullType(a.type)
182 );
183 const paramsSection =
184 requiredArgs.length > 0
185 ? `(${requiredArgs
186 .map((a, i) => `${a.name}: $${i + 1}`)
187 .join(", ")})`
188 : ``;
189
190 const isClientType =
191 parentType.clientSchema &&
192 parentType.clientSchema.localFields &&
193 parentType.clientSchema.localFields.includes(suggestedField.name);
194 const directives = isClientType ? " @client" : "";
195
196 const snippet = hasFields(suggestedField.type)
197 ? `${suggest.label}${paramsSection}${directives} {\n\t$0\n}`
198 : `${suggest.label}${paramsSection}${directives}`;
199
200 return {
201 ...suggest,
202 insertText: snippet,
203 insertTextFormat: InsertTextFormat.Snippet,
204 };
205 }
206 });
207 }
208
209 if (state.kind === "Directive") {
210 return suggestions.map((suggest) => {
211 const directive = project.schema!.getDirective(suggest.label);
212 if (!directive) {
213 return suggest;
214 }
215
216 const requiredArgs = directive.args.filter(isNonNullType);
217 const paramsSection =
218 requiredArgs.length > 0
219 ? `(${requiredArgs
220 .map((a, i) => `${a.name}: $${i + 1}`)
221 .join(", ")})`
222 : ``;
223
224 const snippet = `${suggest.label}${paramsSection}`;
225
226 const argsString =
227 directive.args.length > 0
228 ? `(${directive.args
229 .map((a) => `${a.name}: ${a.type}`)
230 .join(", ")})`
231 : "";
232
233 const content = [
234 [`\`\`\`graphql`, `@${suggest.label}${argsString}`, `\`\`\``].join(
235 "\n"
236 ),
237 ];
238
239 if (suggest.documentation) {
240 if (typeof suggest.documentation === "string") {
241 content.push(suggest.documentation);
242 } else {
243 content.push(suggest.documentation.value);
244 }
245 }
246
247 const doc = {
248 kind: MarkupKind.Markdown,
249 value: content.join("\n\n"),
250 };
251
252 return {
253 ...suggest,
254 documentation: doc,
255 insertText: snippet,
256 insertTextFormat: InsertTextFormat.Snippet,
257 };
258 });
259 }
260
261 return suggestions;
262 }
263
264 async provideHover(
265 uri: DocumentUri,
266 position: Position,
267 _token: CancellationToken
268 ): Promise<Hover | null> {
269 const project = this.workspace.projectForFile(uri);
270 if (!(project && project instanceof GraphQLClientProject)) return null;
271
272 const document = project.documentAt(uri, position);
273 if (!(document && document.ast)) return null;
274
275 if (!project.schema) return null;
276
277 const positionInDocument = positionFromPositionInContainingDocument(
278 document.source,
279 position
280 );
281
282 const nodeAndTypeInfo = getASTNodeAndTypeInfoAtPosition(
283 document.source,
284 positionInDocument,
285 document.ast,
286 project.schema
287 );
288
289 if (nodeAndTypeInfo) {
290 const [node, typeInfo] = nodeAndTypeInfo;
291
292 switch (node.kind) {
293 case Kind.FRAGMENT_SPREAD: {
294 const fragmentName = node.name.value;
295 const fragment = project.fragments[fragmentName];
296 if (fragment) {
297 return {
298 contents: {
299 language: "graphql",
300 value: `fragment ${fragmentName} on ${fragment.typeCondition.name.value}`,
301 },
302 };
303 }
304 break;
305 }
306
307 case Kind.FIELD: {
308 const parentType = typeInfo.getParentType();
309 const fieldDef = typeInfo.getFieldDef();
310
311 if (parentType && fieldDef) {
312 const argsString =
313 fieldDef.args.length > 0
314 ? `(${fieldDef.args
315 .map((a) => `${a.name}: ${a.type}`)
316 .join(", ")})`
317 : "";
318 const isClientType =
319 parentType.clientSchema &&
320 parentType.clientSchema.localFields &&
321 parentType.clientSchema.localFields.includes(fieldDef.name);
322
323 const isResolvedLocally =
324 node.directives &&
325 node.directives.some(
326 (directive) => directive.name.value === "client"
327 );
328
329 const content = [
330 [
331 `\`\`\`graphql`,
332 `${parentType}.${fieldDef.name}${argsString}: ${fieldDef.type}`,
333 `\`\`\``,
334 ].join("\n"),
335 ];
336
337 const info: string[] = [];
338 if (isClientType) {
339 info.push("`Client-Only Field`");
340 }
341 if (isResolvedLocally) {
342 info.push("`Resolved locally`");
343 }
344
345 if (info.length !== 0) {
346 content.push(info.join(" "));
347 }
348
349 if (fieldDef.description) {
350 content.push(fieldDef.description);
351 }
352
353 return {
354 contents: content.join("\n\n---\n\n"),
355 range: rangeForASTNode(highlightNodeForNode(node)),
356 };
357 }
358
359 break;
360 }
361
362 case Kind.NAMED_TYPE: {
363 const type = project.schema.getType(
364 node.name.value
365 ) as GraphQLNamedType | void;
366 if (!type) break;
367
368 const content = [[`\`\`\`graphql`, `${type}`, `\`\`\``].join("\n")];
369
370 if (type.description) {
371 content.push(type.description);
372 }
373
374 return {
375 contents: content.join("\n\n---\n\n"),
376 range: rangeForASTNode(highlightNodeForNode(node)),
377 };
378 }
379
380 case Kind.ARGUMENT: {
381 const argumentNode = typeInfo.getArgument()!;
382 const content = [
383 [
384 `\`\`\`graphql`,
385 `${argumentNode.name}: ${argumentNode.type}`,
386 `\`\`\``,
387 ].join("\n"),
388 ];
389 if (argumentNode.description) {
390 content.push(argumentNode.description);
391 }
392 return {
393 contents: content.join("\n\n---\n\n"),
394 range: rangeForASTNode(highlightNodeForNode(node)),
395 };
396 }
397
398 case Kind.DIRECTIVE: {
399 const directiveNode = typeInfo.getDirective();
400 if (!directiveNode) break;
401 const argsString =
402 directiveNode.args.length > 0
403 ? `(${directiveNode.args
404 .map((a) => `${a.name}: ${a.type}`)
405 .join(", ")})`
406 : "";
407 const content = [
408 [
409 `\`\`\`graphql`,
410 `@${directiveNode.name}${argsString}`,
411 `\`\`\``,
412 ].join("\n"),
413 ];
414 if (directiveNode.description) {
415 content.push(directiveNode.description);
416 }
417 return {
418 contents: content.join("\n\n---\n\n"),
419 range: rangeForASTNode(highlightNodeForNode(node)),
420 };
421 }
422 }
423 }
424 return null;
425 }
426
427 async provideDefinition(
428 uri: DocumentUri,
429 position: Position,
430 _token: CancellationToken
431 ): Promise<Definition | null> {
432 const project = this.workspace.projectForFile(uri);
433 if (!(project && project instanceof GraphQLClientProject)) return null;
434
435 const document = project.documentAt(uri, position);
436 if (!(document && document.ast)) return null;
437
438 if (!project.schema) return null;
439
440 const positionInDocument = positionFromPositionInContainingDocument(
441 document.source,
442 position
443 );
444
445 const nodeAndTypeInfo = getASTNodeAndTypeInfoAtPosition(
446 document.source,
447 positionInDocument,
448 document.ast,
449 project.schema
450 );
451
452 if (nodeAndTypeInfo) {
453 const [node, typeInfo] = nodeAndTypeInfo;
454
455 switch (node.kind) {
456 case Kind.FRAGMENT_SPREAD: {
457 const fragmentName = node.name.value;
458 const fragment = project.fragments[fragmentName];
459 if (fragment && fragment.loc) {
460 return locationForASTNode(fragment);
461 }
462 break;
463 }
464 case Kind.FIELD: {
465 const fieldDef = typeInfo.getFieldDef();
466
467 if (!(fieldDef && fieldDef.astNode && fieldDef.astNode.loc)) break;
468
469 return locationForASTNode(fieldDef.astNode);
470 }
471 case Kind.NAMED_TYPE: {
472 const type = typeFromAST(project.schema, node);
473
474 if (!(type && type.astNode && type.astNode.loc)) break;
475
476 return locationForASTNode(type.astNode);
477 }
478 case Kind.DIRECTIVE: {
479 const directive = project.schema.getDirective(node.name.value);
480
481 if (!(directive && directive.astNode && directive.astNode.loc)) break;
482
483 return locationForASTNode(directive.astNode);
484 }
485 }
486 }
487 return null;
488 }
489
490 async provideReferences(
491 uri: DocumentUri,
492 position: Position,
493 _context: ReferenceContext,
494 _token: CancellationToken
495 ): Promise<Location[] | null> {
496 const project = this.workspace.projectForFile(uri);
497 if (!project) return null;
498 const document = project.documentAt(uri, position);
499 if (!(document && document.ast)) return null;
500
501 if (!project.schema) return null;
502
503 const positionInDocument = positionFromPositionInContainingDocument(
504 document.source,
505 position
506 );
507
508 const nodeAndTypeInfo = getASTNodeAndTypeInfoAtPosition(
509 document.source,
510 positionInDocument,
511 document.ast,
512 project.schema
513 );
514
515 if (nodeAndTypeInfo) {
516 const [node, typeInfo] = nodeAndTypeInfo;
517
518 switch (node.kind) {
519 case Kind.FRAGMENT_DEFINITION: {
520 if (!isClientProject(project)) return null;
521 const fragmentName = node.name.value;
522 return project
523 .fragmentSpreadsForFragment(fragmentName)
524 .map((fragmentSpread) => locationForASTNode(fragmentSpread))
525 .filter(isNotNullOrUndefined);
526 }
527 // TODO(jbaxleyiii): manage no parent type references (unions + scalars)
528 // TODO(jbaxleyiii): support more than fields
529 case Kind.FIELD_DEFINITION: {
530 // case Kind.ENUM_VALUE_DEFINITION:
531 // case Kind.INPUT_OBJECT_TYPE_DEFINITION:
532 // case Kind.INPUT_OBJECT_TYPE_EXTENSION: {
533 if (!isClientProject(project)) return null;
534 const offset = positionToOffset(document.source, positionInDocument);
535 // withWithTypeInfo doesn't suppport SDL so we instead
536 // write our own visitor methods here to collect the fields that we
537 // care about
538 let parent: ASTNode | null = null;
539 visit(document.ast, {
540 enter(node: ASTNode) {
541 // the parent types we care about
542 if (
543 node.loc &&
544 node.loc.start <= offset &&
545 offset <= node.loc.end &&
546 (node.kind === Kind.OBJECT_TYPE_DEFINITION ||
547 node.kind === Kind.OBJECT_TYPE_EXTENSION ||
548 node.kind === Kind.INTERFACE_TYPE_DEFINITION ||
549 node.kind === Kind.INTERFACE_TYPE_EXTENSION ||
550 node.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION ||
551 node.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION ||
552 node.kind === Kind.ENUM_TYPE_DEFINITION ||
553 node.kind === Kind.ENUM_TYPE_EXTENSION)
554 ) {
555 parent = node;
556 }
557 return;
558 },
559 });
560 return project
561 .getOperationFieldsFromFieldDefinition(node.name.value, parent)
562 .map((fieldNode) => locationForASTNode(fieldNode))
563 .filter(isNotNullOrUndefined);
564 }
565 }
566 }
567
568 return null;
569 }
570
571 async provideDocumentSymbol(
572 uri: DocumentUri,
573 _token: CancellationToken
574 ): Promise<DocumentSymbol[]> {
575 const project = this.workspace.projectForFile(uri);
576 if (!project) return [];
577
578 const definitions = project.definitionsAt(uri);
579
580 const symbols: DocumentSymbol[] = [];
581
582 for (const definition of definitions) {
583 if (isExecutableDefinitionNode(definition)) {
584 if (!definition.name) continue;
585 const location = locationForASTNode(definition);
586 if (!location) continue;
587 symbols.push({
588 name: definition.name.value,
589 kind: SymbolKind.Function,
590 range: rangeForASTNode(definition),
591 selectionRange: rangeForASTNode(highlightNodeForNode(definition)),
592 });
593 } else if (
594 isTypeSystemDefinitionNode(definition) ||
595 isTypeSystemExtensionNode(definition)
596 ) {
597 if (
598 definition.kind === Kind.SCHEMA_DEFINITION ||
599 definition.kind === Kind.SCHEMA_EXTENSION
600 ) {
601 continue;
602 }
603 symbols.push({
604 name: definition.name.value,
605 kind: SymbolKind.Class,
606 range: rangeForASTNode(definition),
607 selectionRange: rangeForASTNode(highlightNodeForNode(definition)),
608 children:
609 definition.kind === Kind.OBJECT_TYPE_DEFINITION ||
610 definition.kind === Kind.OBJECT_TYPE_EXTENSION
611 ? (definition.fields || []).map(symbolForFieldDefinition)
612 : undefined,
613 });
614 }
615 }
616
617 return symbols;
618 }
619
620 async provideWorkspaceSymbol(
621 query: string,
622 _token: CancellationToken
623 ): Promise<SymbolInformation[]> {
624 const symbols: SymbolInformation[] = [];
625 for (const project of this.workspace.projects) {
626 for (const definition of project.definitions) {
627 if (isExecutableDefinitionNode(definition)) {
628 if (!definition.name) continue;
629 const location = locationForASTNode(definition);
630 if (!location) continue;
631 symbols.push({
632 name: definition.name.value,
633 kind: SymbolKind.Function,
634 location,
635 });
636 }
637 }
638 }
639 return symbols;
640 }
641
642 async provideCodeLenses(
643 uri: DocumentUri,
644 _token: CancellationToken
645 ): Promise<CodeLens[]> {
646 const project = this.workspace.projectForFile(uri);
647 if (!(project && project instanceof GraphQLClientProject)) return [];
648
649 // Wait for the project to be fully initialized, so we always provide code lenses for open files, even
650 // if we receive the request before the project is ready.
651 await project.whenReady;
652
653 const documents = project.documentsAt(uri);
654 if (!documents) return [];
655
656 let codeLenses: CodeLens[] = [];
657
658 for (const document of documents) {
659 if (!document.ast) continue;
660
661 for (const definition of document.ast.definitions) {
662 if (definition.kind === Kind.OPERATION_DEFINITION) {
663 /*
664 if (set.endpoint) {
665 const fragmentSpreads: Set<
666 graphql.FragmentDefinitionNode
667 > = new Set();
668 const searchForReferencedFragments = (node: graphql.ASTNode) => {
669 visit(node, {
670 FragmentSpread(node: FragmentSpreadNode) {
671 const fragDefn = project.fragments[node.name.value];
672 if (!fragDefn) return;
673
674 if (!fragmentSpreads.has(fragDefn)) {
675 fragmentSpreads.add(fragDefn);
676 searchForReferencedFragments(fragDefn);
677 }
678 }
679 });
680 };
681
682 searchForReferencedFragments(definition);
683
684 codeLenses.push({
685 range: rangeForASTNode(definition),
686 command: Command.create(
687 `Run ${definition.operation}`,
688 "apollographql.runQuery",
689 graphql.parse(
690 [definition, ...fragmentSpreads]
691 .map(n => graphql.print(n))
692 .join("\n")
693 ),
694 definition.operation === "subscription"
695 ? set.endpoint.subscriptions
696 : set.endpoint.url,
697 set.endpoint.headers,
698 graphql.printSchema(set.schema!)
699 )
700 });
701 }
702 */
703 } else if (definition.kind === Kind.FRAGMENT_DEFINITION) {
704 // remove project references for fragment now
705 // const fragmentName = definition.name.value;
706 // const locations = project
707 // .fragmentSpreadsForFragment(fragmentName)
708 // .map(fragmentSpread => locationForASTNode(fragmentSpread))
709 // .filter(isNotNullOrUndefined);
710 // const command = Command.create(
711 // `${locations.length} references`,
712 // "editor.action.showReferences",
713 // uri,
714 // rangeForASTNode(definition).start,
715 // locations
716 // );
717 // codeLenses.push({
718 // range: rangeForASTNode(definition),
719 // command
720 // });
721 }
722 }
723 }
724 return codeLenses;
725 }
726
727 async provideCodeAction(
728 uri: DocumentUri,
729 range: Range,
730 _token: CancellationToken
731 ): Promise<CodeAction[]> {
732 function isPositionLessThanOrEqual(a: Position, b: Position) {
733 return a.line !== b.line ? a.line < b.line : a.character <= b.character;
734 }
735
736 const project = this.workspace.projectForFile(uri);
737 if (
738 !(
739 project &&
740 project instanceof GraphQLClientProject &&
741 project.diagnosticSet
742 )
743 )
744 return [];
745
746 await project.whenReady;
747
748 const documents = project.documentsAt(uri);
749 if (!documents) return [];
750
751 const errors: Set<GraphQLError> = new Set();
752
753 for (const [
754 diagnosticUri,
755 diagnostics,
756 ] of project.diagnosticSet.entries()) {
757 if (diagnosticUri !== uri) continue;
758
759 for (const diagnostic of diagnostics) {
760 if (
761 GraphQLDiagnostic.is(diagnostic) &&
762 isPositionLessThanOrEqual(range.start, diagnostic.range.end) &&
763 isPositionLessThanOrEqual(diagnostic.range.start, range.end)
764 ) {
765 errors.add(diagnostic.error);
766 }
767 }
768 }
769
770 const result: CodeAction[] = [];
771
772 for (const error of errors) {
773 const { extensions } = error;
774 if (!extensions || !extensions.codeAction) continue;
775
776 const { message, edits }: CodeActionInfo = extensions.codeAction;
777
778 const codeAction = CodeAction.create(
779 message,
780 { changes: { [uri]: edits } },
781 CodeActionKind.QuickFix
782 );
783
784 result.push(codeAction);
785 }
786
787 return result;
788 }
789}