+5
.changeset/gold-apricots-report.md
+5
.changeset/gold-apricots-report.md
+278
src/__tests__/description.test.ts
+278
src/__tests__/description.test.ts
···
1
+
import { describe, it, expect } from 'vitest';
2
+
import { parse } from '../parser';
3
+
import { print } from '../printer';
4
+
import type {
5
+
OperationDefinitionNode,
6
+
VariableDefinitionNode,
7
+
FragmentDefinitionNode,
8
+
} from '../ast';
9
+
10
+
describe('GraphQL descriptions', () => {
11
+
describe('OperationDefinition descriptions', () => {
12
+
it('parses operation with description', () => {
13
+
const source = `
14
+
"""
15
+
Request the current status of a time machine and its operator.
16
+
"""
17
+
query GetTimeMachineStatus {
18
+
timeMachine {
19
+
id
20
+
status
21
+
}
22
+
}
23
+
`;
24
+
25
+
const doc = parse(source, { noLocation: true });
26
+
const operation = doc.definitions[0] as OperationDefinitionNode;
27
+
28
+
expect(operation.description).toBeDefined();
29
+
expect(operation.description?.value).toBe(
30
+
'Request the current status of a time machine and its operator.'
31
+
);
32
+
expect(operation.description?.block).toBe(true);
33
+
});
34
+
35
+
it('parses operation with single-line description', () => {
36
+
const source = `
37
+
"Simple query description"
38
+
query SimpleQuery {
39
+
field
40
+
}
41
+
`;
42
+
43
+
const doc = parse(source, { noLocation: true });
44
+
const operation = doc.definitions[0] as OperationDefinitionNode;
45
+
46
+
expect(operation.description).toBeDefined();
47
+
expect(operation.description?.value).toBe('Simple query description');
48
+
expect(operation.description?.block).toBe(false);
49
+
});
50
+
51
+
it('does not allow description on anonymous operations', () => {
52
+
const source = `
53
+
"This should fail"
54
+
{
55
+
field
56
+
}
57
+
`;
58
+
59
+
expect(() => parse(source)).toThrow();
60
+
});
61
+
62
+
it('parses mutation with description', () => {
63
+
const source = `
64
+
"""
65
+
Create a new time machine entry.
66
+
"""
67
+
mutation CreateTimeMachine($input: TimeMachineInput!) {
68
+
createTimeMachine(input: $input) {
69
+
id
70
+
}
71
+
}
72
+
`;
73
+
74
+
const doc = parse(source, { noLocation: true });
75
+
const operation = doc.definitions[0] as OperationDefinitionNode;
76
+
77
+
expect(operation.description).toBeDefined();
78
+
expect(operation.description?.value).toBe('Create a new time machine entry.');
79
+
});
80
+
});
81
+
82
+
describe('VariableDefinition descriptions', () => {
83
+
it('parses variable with description', () => {
84
+
const source = `
85
+
query GetTimeMachineStatus(
86
+
"The unique serial number of the time machine to inspect."
87
+
$machineId: ID!
88
+
89
+
"""
90
+
The year to check the status for.
91
+
**Warning:** certain years may trigger an anomaly in the space-time continuum.
92
+
"""
93
+
$year: Int
94
+
) {
95
+
timeMachine(id: $machineId) {
96
+
status(year: $year)
97
+
}
98
+
}
99
+
`;
100
+
101
+
const doc = parse(source, { noLocation: true });
102
+
const operation = doc.definitions[0] as OperationDefinitionNode;
103
+
const variables = operation.variableDefinitions as VariableDefinitionNode[];
104
+
105
+
expect(variables[0].description).toBeDefined();
106
+
expect(variables[0].description?.value).toBe(
107
+
'The unique serial number of the time machine to inspect.'
108
+
);
109
+
expect(variables[0].description?.block).toBe(false);
110
+
111
+
expect(variables[1].description).toBeDefined();
112
+
expect(variables[1].description?.value).toBe(
113
+
'The year to check the status for.\n**Warning:** certain years may trigger an anomaly in the space-time continuum.'
114
+
);
115
+
expect(variables[1].description?.block).toBe(true);
116
+
});
117
+
118
+
it('parses mixed variables with and without descriptions', () => {
119
+
const source = `
120
+
query Mixed(
121
+
"Described variable"
122
+
$described: String
123
+
$undescribed: Int
124
+
) {
125
+
field
126
+
}
127
+
`;
128
+
129
+
const doc = parse(source, { noLocation: true });
130
+
const operation = doc.definitions[0] as OperationDefinitionNode;
131
+
const variables = operation.variableDefinitions as VariableDefinitionNode[];
132
+
133
+
expect(variables[0].description).toBeDefined();
134
+
expect(variables[0].description?.value).toBe('Described variable');
135
+
expect(variables[1].description).toBeUndefined();
136
+
});
137
+
});
138
+
139
+
describe('FragmentDefinition descriptions', () => {
140
+
it('parses fragment with description', () => {
141
+
const source = `
142
+
"Time machine details."
143
+
fragment TimeMachineDetails on TimeMachine {
144
+
id
145
+
model
146
+
lastMaintenance
147
+
}
148
+
`;
149
+
150
+
const doc = parse(source, { noLocation: true });
151
+
const fragment = doc.definitions[0] as FragmentDefinitionNode;
152
+
153
+
expect(fragment.description).toBeDefined();
154
+
expect(fragment.description?.value).toBe('Time machine details.');
155
+
expect(fragment.description?.block).toBe(false);
156
+
});
157
+
158
+
it('parses fragment with block description', () => {
159
+
const source = `
160
+
"""
161
+
Comprehensive time machine information
162
+
including maintenance history and operational status.
163
+
"""
164
+
fragment FullTimeMachineInfo on TimeMachine {
165
+
id
166
+
model
167
+
lastMaintenance
168
+
operationalStatus
169
+
}
170
+
`;
171
+
172
+
const doc = parse(source, { noLocation: true });
173
+
const fragment = doc.definitions[0] as FragmentDefinitionNode;
174
+
175
+
expect(fragment.description).toBeDefined();
176
+
expect(fragment.description?.value).toBe(
177
+
'Comprehensive time machine information\nincluding maintenance history and operational status.'
178
+
);
179
+
expect(fragment.description?.block).toBe(true);
180
+
});
181
+
});
182
+
183
+
describe('print with descriptions', () => {
184
+
it('prints operation description correctly', () => {
185
+
const source = `"""
186
+
Request the current status of a time machine and its operator.
187
+
"""
188
+
query GetTimeMachineStatus {
189
+
timeMachine {
190
+
id
191
+
}
192
+
}`;
193
+
194
+
const doc = parse(source, { noLocation: true });
195
+
const printed = print(doc);
196
+
197
+
expect(printed).toContain('"""');
198
+
expect(printed).toContain('Request the current status of a time machine and its operator.');
199
+
});
200
+
201
+
it('prints variable descriptions correctly', () => {
202
+
const source = `query GetStatus(
203
+
"Machine ID"
204
+
$id: ID!
205
+
) {
206
+
field
207
+
}`;
208
+
209
+
const doc = parse(source, { noLocation: true });
210
+
const printed = print(doc);
211
+
212
+
expect(printed).toContain('"Machine ID"');
213
+
});
214
+
215
+
it('prints fragment description correctly', () => {
216
+
const source = `"Details fragment"
217
+
fragment Details on Type {
218
+
field
219
+
}`;
220
+
221
+
const doc = parse(source, { noLocation: true });
222
+
const printed = print(doc);
223
+
224
+
expect(printed).toContain('"Details fragment"');
225
+
});
226
+
});
227
+
228
+
describe('roundtrip parsing and printing', () => {
229
+
it('maintains descriptions through parse and print cycle', () => {
230
+
const source = `"""
231
+
Request the current status of a time machine and its operator.
232
+
"""
233
+
query GetTimeMachineStatus(
234
+
"The unique serial number of the time machine to inspect."
235
+
$machineId: ID!
236
+
237
+
"""
238
+
The year to check the status for.
239
+
**Warning:** certain years may trigger an anomaly in the space-time continuum.
240
+
"""
241
+
$year: Int
242
+
) {
243
+
timeMachine(id: $machineId) {
244
+
...TimeMachineDetails
245
+
operator {
246
+
name
247
+
licenseLevel
248
+
}
249
+
status(year: $year)
250
+
}
251
+
}
252
+
253
+
"Time machine details."
254
+
fragment TimeMachineDetails on TimeMachine {
255
+
id
256
+
model
257
+
lastMaintenance
258
+
}`;
259
+
260
+
const doc = parse(source, { noLocation: true });
261
+
const printed = print(doc);
262
+
const reparsed = parse(printed, { noLocation: true });
263
+
264
+
const operation = doc.definitions[0] as OperationDefinitionNode;
265
+
const reparsedOperation = reparsed.definitions[0] as OperationDefinitionNode;
266
+
267
+
// The printed/reparsed cycle may have slightly different formatting but same content
268
+
expect(reparsedOperation.description?.value?.trim()).toBe(
269
+
operation.description?.value?.trim()
270
+
);
271
+
272
+
const fragment = doc.definitions[1] as FragmentDefinitionNode;
273
+
const reparsedFragment = reparsed.definitions[1] as FragmentDefinitionNode;
274
+
275
+
expect(reparsedFragment.description?.value).toBe(fragment.description?.value);
276
+
});
277
+
});
278
+
});
+6
-3
src/ast.ts
+6
-3
src/ast.ts
···
103
103
>;
104
104
105
105
export type OperationDefinitionNode = Or<
106
-
GraphQL.OperationDefinitionNode,
106
+
GraphQL.OperationDefinitionNode & { description?: StringValueNode },
107
107
{
108
108
readonly kind: Kind.OPERATION_DEFINITION;
109
109
readonly operation: OperationTypeNode;
110
110
readonly name?: NameNode;
111
+
readonly description?: StringValueNode;
111
112
readonly variableDefinitions?: ReadonlyArray<VariableDefinitionNode>;
112
113
readonly directives?: ReadonlyArray<DirectiveNode>;
113
114
readonly selectionSet: SelectionSetNode;
···
116
117
>;
117
118
118
119
export type VariableDefinitionNode = Or<
119
-
GraphQL.VariableDefinitionNode,
120
+
GraphQL.VariableDefinitionNode & { description?: StringValueNode },
120
121
{
121
122
readonly kind: Kind.VARIABLE_DEFINITION;
122
123
readonly variable: VariableNode;
123
124
readonly type: TypeNode;
124
125
readonly defaultValue?: ConstValueNode;
126
+
readonly description?: StringValueNode;
125
127
readonly directives?: ReadonlyArray<ConstDirectiveNode>;
126
128
readonly loc?: Location;
127
129
}
···
205
207
>;
206
208
207
209
export type FragmentDefinitionNode = Or<
208
-
GraphQL.FragmentDefinitionNode,
210
+
GraphQL.FragmentDefinitionNode & { description?: StringValueNode },
209
211
{
210
212
readonly kind: Kind.FRAGMENT_DEFINITION;
211
213
readonly name: NameNode;
214
+
readonly description?: StringValueNode;
212
215
readonly typeCondition: NamedTypeNode;
213
216
readonly directives?: ReadonlyArray<DirectiveNode>;
214
217
readonly selectionSet: SelectionSetNode;
+29
-7
src/parser.ts
+29
-7
src/parser.ts
···
421
421
idx++;
422
422
ignored();
423
423
do {
424
+
let _description: ast.StringValueNode | undefined;
425
+
if (input.charCodeAt(idx) === 34 /*'"'*/) {
426
+
_description = value(true) as ast.StringValueNode;
427
+
}
424
428
if (input.charCodeAt(idx++) !== 36 /*'$'*/) throw error('Variable');
425
429
const name = nameNode();
426
430
if (input.charCodeAt(idx++) !== 58 /*':'*/) throw error('VariableDefinition');
···
433
437
_defaultValue = value(true);
434
438
}
435
439
ignored();
436
-
vars.push({
440
+
const varDef: ast.VariableDefinitionNode = {
437
441
kind: 'VariableDefinition' as Kind.VARIABLE_DEFINITION,
438
442
variable: {
439
443
kind: 'Variable' as Kind.VARIABLE,
···
442
446
type: _type,
443
447
defaultValue: _defaultValue,
444
448
directives: directives(true),
445
-
});
449
+
};
450
+
if (_description) {
451
+
varDef.description = _description;
452
+
}
453
+
vars.push(varDef);
446
454
} while (input.charCodeAt(idx) !== 41 /*')'*/);
447
455
idx++;
448
456
ignored();
···
450
458
}
451
459
}
452
460
453
-
function fragmentDefinition(): ast.FragmentDefinitionNode {
461
+
function fragmentDefinition(description?: ast.StringValueNode): ast.FragmentDefinitionNode {
454
462
const name = nameNode();
455
463
if (input.charCodeAt(idx++) !== 111 /*'o'*/ || input.charCodeAt(idx++) !== 110 /*'n'*/)
456
464
throw error('FragmentDefinition');
457
465
ignored();
458
-
return {
466
+
const fragDef: ast.FragmentDefinitionNode = {
459
467
kind: 'FragmentDefinition' as Kind.FRAGMENT_DEFINITION,
460
468
name,
461
469
typeCondition: {
···
465
473
directives: directives(false),
466
474
selectionSet: selectionSetStart(),
467
475
};
476
+
if (description) {
477
+
fragDef.description = description;
478
+
}
479
+
return fragDef;
468
480
}
469
481
470
482
function definitions(): ast.DefinitionNode[] {
471
483
const _definitions: ast.ExecutableDefinitionNode[] = [];
472
484
do {
485
+
let _description: ast.StringValueNode | undefined;
486
+
if (input.charCodeAt(idx) === 34 /*'"'*/) {
487
+
_description = value(true) as ast.StringValueNode;
488
+
}
473
489
if (input.charCodeAt(idx) === 123 /*'{'*/) {
490
+
// Anonymous operations can't have descriptions
491
+
if (_description) throw error('Document');
474
492
idx++;
475
493
ignored();
476
494
_definitions.push({
···
485
503
const definition = name();
486
504
switch (definition) {
487
505
case 'fragment':
488
-
_definitions.push(fragmentDefinition());
506
+
_definitions.push(fragmentDefinition(_description));
489
507
break;
490
508
case 'query':
491
509
case 'mutation':
···
499
517
) {
500
518
name = nameNode();
501
519
}
502
-
_definitions.push({
520
+
const opDef: ast.OperationDefinitionNode = {
503
521
kind: 'OperationDefinition' as Kind.OPERATION_DEFINITION,
504
522
operation: definition as OperationTypeNode,
505
523
name,
506
524
variableDefinitions: variableDefinitions(),
507
525
directives: directives(false),
508
526
selectionSet: selectionSetStart(),
509
-
});
527
+
};
528
+
if (_description) {
529
+
opDef.description = _description;
530
+
}
531
+
_definitions.push(opDef);
510
532
break;
511
533
default:
512
534
throw error('Document');
+17
-6
src/printer.ts
+17
-6
src/printer.ts
···
49
49
50
50
const nodes = {
51
51
OperationDefinition(node: OperationDefinitionNode): string {
52
-
let out: string = node.operation;
52
+
let out: string = '';
53
+
if (node.description) {
54
+
out += nodes.StringValue(node.description) + '\n';
55
+
}
56
+
out += node.operation;
53
57
if (node.name) out += ' ' + node.name.value;
54
58
if (node.variableDefinitions && node.variableDefinitions.length) {
55
59
if (!node.name) out += ' ';
···
57
61
}
58
62
if (node.directives && node.directives.length)
59
63
out += ' ' + mapJoin(node.directives, ' ', nodes.Directive);
60
-
return out !== 'query'
61
-
? out + ' ' + nodes.SelectionSet(node.selectionSet)
62
-
: nodes.SelectionSet(node.selectionSet);
64
+
const selectionSet = nodes.SelectionSet(node.selectionSet);
65
+
return out !== 'query' ? out + ' ' + selectionSet : selectionSet;
63
66
},
64
67
VariableDefinition(node: VariableDefinitionNode): string {
65
-
let out = nodes.Variable!(node.variable) + ': ' + _print(node.type);
68
+
let out = '';
69
+
if (node.description) {
70
+
out += nodes.StringValue(node.description) + ' ';
71
+
}
72
+
out += nodes.Variable!(node.variable) + ': ' + _print(node.type);
66
73
if (node.defaultValue) out += ' = ' + _print(node.defaultValue);
67
74
if (node.directives && node.directives.length)
68
75
out += ' ' + mapJoin(node.directives, ' ', nodes.Directive);
···
152
159
return out;
153
160
},
154
161
FragmentDefinition(node: FragmentDefinitionNode): string {
155
-
let out = 'fragment ' + node.name.value;
162
+
let out = '';
163
+
if (node.description) {
164
+
out += nodes.StringValue(node.description) + '\n';
165
+
}
166
+
out += 'fragment ' + node.name.value;
156
167
out += ' on ' + node.typeCondition.name.value;
157
168
if (node.directives && node.directives.length)
158
169
out += ' ' + mapJoin(node.directives, ' ', nodes.Directive);