Mirror: The spec-compliant minimum of client-side GraphQL.
1/**
2 * This is a spec-compliant implementation of a GraphQL query language parser,
3 * up-to-date with the October 2021 Edition. Unlike the reference implementation
4 * in graphql.js it will only parse the query language, but not the schema
5 * language.
6 */
7import type { Kind, OperationTypeNode } from './kind';
8import { GraphQLError } from './error';
9import type { Source } from './types';
10import type * as ast from './ast';
11
12let input: string;
13let idx: number;
14
15function error(kind: string) {
16 return new GraphQLError(`Syntax Error: Unexpected token at ${idx} in ${kind}`);
17}
18
19function advance(pattern: RegExp) {
20 pattern.lastIndex = idx;
21 if (pattern.test(input)) {
22 const match = input.slice(idx, (idx = pattern.lastIndex));
23 return match;
24 }
25}
26
27const leadingRe = / +(?=[^\s])/y;
28function blockString(string: string) {
29 const lines = string.split('\n');
30 let out = '';
31 let commonIndent = 0;
32 let firstNonEmptyLine = 0;
33 let lastNonEmptyLine = lines.length - 1;
34 for (let i = 0; i < lines.length; i++) {
35 leadingRe.lastIndex = 0;
36 if (leadingRe.test(lines[i])) {
37 if (i && (!commonIndent || leadingRe.lastIndex < commonIndent))
38 commonIndent = leadingRe.lastIndex;
39 firstNonEmptyLine = firstNonEmptyLine || i;
40 lastNonEmptyLine = i;
41 }
42 }
43 for (let i = firstNonEmptyLine; i <= lastNonEmptyLine; i++) {
44 if (i !== firstNonEmptyLine) out += '\n';
45 out += lines[i].slice(commonIndent).replace(/\\"""/g, '"""');
46 }
47 return out;
48}
49
50// Note: This is equivalent to: /(?:[\s,]*|#[^\n\r]*)*/y
51function ignored() {
52 for (
53 let char = input.charCodeAt(idx++) | 0;
54 char === 9 /*'\t'*/ ||
55 char === 10 /*'\n'*/ ||
56 char === 13 /*'\r'*/ ||
57 char === 32 /*' '*/ ||
58 char === 35 /*'#'*/ ||
59 char === 44 /*','*/ ||
60 char === 65279 /*'\ufeff'*/;
61 char = input.charCodeAt(idx++) | 0
62 ) {
63 if (char === 35 /*'#'*/) while ((char = input.charCodeAt(idx++)) !== 10 && char !== 13);
64 }
65 idx--;
66}
67
68const nameRe = /[_A-Za-z]\w*/y;
69
70// NOTE: This should be compressed by our build step
71// This merges all possible value parsing into one regular expression
72const valueRe = new RegExp(
73 '(?:' +
74 // `null`, `true`, and `false` literals (BooleanValue & NullValue)
75 '(null|true|false)|' +
76 // Variables starting with `$` then having a name (VariableNode)
77 '\\$(' +
78 nameRe.source +
79 ')|' +
80 // Numbers, starting with int then optionally following with a float part (IntValue and FloatValue)
81 '(-?\\d+)((?:\\.\\d+)?[eE][+-]?\\d+|\\.\\d+)?|' +
82 // Block strings starting with `"""` until the next unescaped `"""` (StringValue)
83 '("""(?:"""|(?:[\\s\\S]*?[^\\\\])"""))|' +
84 // Strings starting with `"` must be on one line (StringValue)
85 '("(?:"|[^\\r\\n]*?[^\\\\]"))|' + // string
86 // Enums are simply names except for our literals (EnumValue)
87 '(' +
88 nameRe.source +
89 '))',
90 'y'
91);
92
93// NOTE: Each of the groups above end up in the RegExpExecArray at the specified indices (starting with 1)
94const enum ValueGroup {
95 Const = 1,
96 Var,
97 Int,
98 Float,
99 BlockString,
100 String,
101 Enum,
102}
103
104type ValueExec = RegExpExecArray & {
105 [Prop in ValueGroup]: string | undefined;
106};
107
108const complexStringRe = /\\/g;
109
110function value(constant: true): ast.ConstValueNode;
111function value(constant: boolean): ast.ValueNode;
112
113function value(constant: boolean): ast.ValueNode {
114 let match: string | undefined;
115 let exec: ValueExec | null;
116 valueRe.lastIndex = idx;
117 if (input.charCodeAt(idx) === 91 /*'['*/) {
118 // Lists are checked ahead of time with `[` chars
119 idx++;
120 ignored();
121 const values: ast.ValueNode[] = [];
122 while (input.charCodeAt(idx) !== 93 /*']'*/) values.push(value(constant));
123 idx++;
124 ignored();
125 return {
126 kind: 'ListValue' as Kind.LIST,
127 values,
128 };
129 } else if (input.charCodeAt(idx) === 123 /*'{'*/) {
130 // Objects are checked ahead of time with `{` chars
131 idx++;
132 ignored();
133 const fields: ast.ObjectFieldNode[] = [];
134 while (input.charCodeAt(idx) !== 125 /*'}'*/) {
135 if ((match = advance(nameRe)) == null) throw error('ObjectField');
136 ignored();
137 if (input.charCodeAt(idx++) !== 58 /*':'*/) throw error('ObjectField');
138 ignored();
139 fields.push({
140 kind: 'ObjectField' as Kind.OBJECT_FIELD,
141 name: { kind: 'Name' as Kind.NAME, value: match },
142 value: value(constant),
143 });
144 }
145 idx++;
146 ignored();
147 return {
148 kind: 'ObjectValue' as Kind.OBJECT,
149 fields,
150 };
151 } else if ((exec = valueRe.exec(input) as ValueExec) != null) {
152 // Starting from here, the merged `valueRe` is used
153 idx = valueRe.lastIndex;
154 ignored();
155 if ((match = exec[ValueGroup.Const]) != null) {
156 return match === 'null'
157 ? { kind: 'NullValue' as Kind.NULL }
158 : {
159 kind: 'BooleanValue' as Kind.BOOLEAN,
160 value: match === 'true',
161 };
162 } else if ((match = exec[ValueGroup.Var]) != null) {
163 if (constant) {
164 throw error('Variable');
165 } else {
166 return {
167 kind: 'Variable' as Kind.VARIABLE,
168 name: {
169 kind: 'Name' as Kind.NAME,
170 value: match,
171 },
172 };
173 }
174 } else if ((match = exec[ValueGroup.Int]) != null) {
175 let floatPart: string | undefined;
176 if ((floatPart = exec[ValueGroup.Float]) != null) {
177 return {
178 kind: 'FloatValue' as Kind.FLOAT,
179 value: match + floatPart,
180 };
181 } else {
182 return {
183 kind: 'IntValue' as Kind.INT,
184 value: match,
185 };
186 }
187 } else if ((match = exec[ValueGroup.BlockString]) != null) {
188 return {
189 kind: 'StringValue' as Kind.STRING,
190 value: blockString(match.slice(3, -3)),
191 block: true,
192 };
193 } else if ((match = exec[ValueGroup.String]) != null) {
194 return {
195 kind: 'StringValue' as Kind.STRING,
196 // When strings don't contain escape codes, a simple slice will be enough, otherwise
197 // `JSON.parse` matches GraphQL's string parsing perfectly
198 value: complexStringRe.test(match) ? (JSON.parse(match) as string) : match.slice(1, -1),
199 block: false,
200 };
201 } else if ((match = exec[ValueGroup.Enum]) != null) {
202 return {
203 kind: 'EnumValue' as Kind.ENUM,
204 value: match,
205 };
206 }
207 }
208
209 throw error('Value');
210}
211
212function arguments_(constant: boolean): ast.ArgumentNode[] | undefined {
213 if (input.charCodeAt(idx) === 40 /*'('*/) {
214 const args: ast.ArgumentNode[] = [];
215 idx++;
216 ignored();
217 let _name: string | undefined;
218 do {
219 if ((_name = advance(nameRe)) == null) throw error('Argument');
220 ignored();
221 if (input.charCodeAt(idx++) !== 58 /*':'*/) throw error('Argument');
222 ignored();
223 args.push({
224 kind: 'Argument' as Kind.ARGUMENT,
225 name: { kind: 'Name' as Kind.NAME, value: _name },
226 value: value(constant),
227 });
228 } while (input.charCodeAt(idx) !== 41 /*')'*/);
229 idx++;
230 ignored();
231 return args;
232 }
233}
234
235function directives(constant: true): ast.ConstDirectiveNode[] | undefined;
236function directives(constant: boolean): ast.DirectiveNode[] | undefined;
237
238function directives(constant: boolean): ast.DirectiveNode[] | undefined {
239 if (input.charCodeAt(idx) === 64 /*'@'*/) {
240 const directives: ast.DirectiveNode[] = [];
241 let _name: string | undefined;
242 do {
243 idx++;
244 if ((_name = advance(nameRe)) == null) throw error('Directive');
245 ignored();
246 directives.push({
247 kind: 'Directive' as Kind.DIRECTIVE,
248 name: { kind: 'Name' as Kind.NAME, value: _name },
249 arguments: arguments_(constant),
250 });
251 } while (input.charCodeAt(idx) === 64 /*'@'*/);
252 return directives;
253 }
254}
255
256function type(): ast.TypeNode {
257 let match: string | undefined;
258 let lists = 0;
259 while (input.charCodeAt(idx) === 91 /*'['*/) {
260 lists++;
261 idx++;
262 ignored();
263 }
264 if ((match = advance(nameRe)) == null) throw error('NamedType');
265 ignored();
266 let type: ast.TypeNode = {
267 kind: 'NamedType' as Kind.NAMED_TYPE,
268 name: { kind: 'Name' as Kind.NAME, value: match },
269 };
270 do {
271 if (input.charCodeAt(idx) === 33 /*'!'*/) {
272 idx++;
273 ignored();
274 type = {
275 kind: 'NonNullType' as Kind.NON_NULL_TYPE,
276 type: type as ast.NamedTypeNode | ast.ListTypeNode,
277 } satisfies ast.NonNullTypeNode;
278 }
279 if (lists) {
280 if (input.charCodeAt(idx++) !== 93 /*']'*/) throw error('NamedType');
281 ignored();
282 type = {
283 kind: 'ListType' as Kind.LIST_TYPE,
284 type: type as ast.NamedTypeNode | ast.ListTypeNode,
285 } satisfies ast.ListTypeNode;
286 }
287 } while (lists--);
288 return type;
289}
290
291// NOTE: This should be compressed by our build step
292// This merges the two possible selection parsing branches into one regular expression
293const selectionRe = new RegExp(
294 '(?:' +
295 // fragment spreads (FragmentSpread or InlineFragment nodes)
296 '(\\.\\.\\.)|' +
297 // field aliases or names (FieldNode)
298 '(' +
299 nameRe.source +
300 '))',
301 'y'
302);
303
304// NOTE: Each of the groups above end up in the RegExpExecArray at the indices 1&2
305const enum SelectionGroup {
306 Spread = 1,
307 Name,
308}
309
310type SelectionExec = RegExpExecArray & {
311 [Prop in SelectionGroup]: string | undefined;
312};
313
314function selectionSet(): ast.SelectionSetNode {
315 const selections: ast.SelectionNode[] = [];
316 let match: string | undefined;
317 let exec: SelectionExec | null;
318 do {
319 selectionRe.lastIndex = idx;
320 if ((exec = selectionRe.exec(input) as SelectionExec) != null) {
321 idx = selectionRe.lastIndex;
322 if (exec[SelectionGroup.Spread] != null) {
323 ignored();
324 let match = advance(nameRe);
325 if (match != null && match !== 'on') {
326 // A simple `...Name` spread with optional directives
327 ignored();
328 selections.push({
329 kind: 'FragmentSpread' as Kind.FRAGMENT_SPREAD,
330 name: { kind: 'Name' as Kind.NAME, value: match },
331 directives: directives(false),
332 });
333 } else {
334 ignored();
335 if (match === 'on') {
336 // An inline `... on Name` spread; if this doesn't match, the type condition has been omitted
337 if ((match = advance(nameRe)) == null) throw error('NamedType');
338 ignored();
339 }
340 const _directives = directives(false);
341 if (input.charCodeAt(idx++) !== 123 /*'{'*/) throw error('InlineFragment');
342 ignored();
343 selections.push({
344 kind: 'InlineFragment' as Kind.INLINE_FRAGMENT,
345 typeCondition: match
346 ? {
347 kind: 'NamedType' as Kind.NAMED_TYPE,
348 name: { kind: 'Name' as Kind.NAME, value: match },
349 }
350 : undefined,
351 directives: _directives,
352 selectionSet: selectionSet(),
353 });
354 }
355 } else if ((match = exec[SelectionGroup.Name]) != null) {
356 let _alias: string | undefined;
357 ignored();
358 // Parse the optional alias, by reassigning and then getting the name
359 if (input.charCodeAt(idx) === 58 /*':'*/) {
360 idx++;
361 ignored();
362 _alias = match;
363 if ((match = advance(nameRe)) == null) throw error('Field');
364 }
365 const _arguments = arguments_(false);
366 ignored();
367 const _directives = directives(false);
368 let _selectionSet: ast.SelectionSetNode | undefined;
369 if (input.charCodeAt(idx) === 123 /*'{'*/) {
370 idx++;
371 ignored();
372 _selectionSet = selectionSet();
373 }
374 selections.push({
375 kind: 'Field' as Kind.FIELD,
376 alias: _alias ? { kind: 'Name' as Kind.NAME, value: _alias } : undefined,
377 name: { kind: 'Name' as Kind.NAME, value: match },
378 arguments: _arguments,
379 directives: _directives,
380 selectionSet: _selectionSet,
381 });
382 }
383 } else {
384 throw error('SelectionSet');
385 }
386 } while (input.charCodeAt(idx) !== 125 /*'}'*/);
387 idx++;
388 ignored();
389 return {
390 kind: 'SelectionSet' as Kind.SELECTION_SET,
391 selections,
392 };
393}
394
395function variableDefinitions(): ast.VariableDefinitionNode[] | undefined {
396 ignored();
397 if (input.charCodeAt(idx) === 40 /*'('*/) {
398 const vars: ast.VariableDefinitionNode[] = [];
399 idx++;
400 ignored();
401 let _name: string | undefined;
402 do {
403 if (input.charCodeAt(idx++) !== 36 /*'$'*/) throw error('Variable');
404 if ((_name = advance(nameRe)) == null) throw error('Variable');
405 ignored();
406 if (input.charCodeAt(idx++) !== 58 /*':'*/) throw error('VariableDefinition');
407 ignored();
408 const _type = type();
409 let _defaultValue: ast.ConstValueNode | undefined;
410 if (input.charCodeAt(idx) === 61 /*'='*/) {
411 idx++;
412 ignored();
413 _defaultValue = value(true);
414 }
415 ignored();
416 vars.push({
417 kind: 'VariableDefinition' as Kind.VARIABLE_DEFINITION,
418 variable: {
419 kind: 'Variable' as Kind.VARIABLE,
420 name: { kind: 'Name' as Kind.NAME, value: _name },
421 },
422 type: _type,
423 defaultValue: _defaultValue,
424 directives: directives(true),
425 });
426 } while (input.charCodeAt(idx) !== 41 /*')'*/);
427 idx++;
428 ignored();
429 return vars;
430 }
431}
432
433function fragmentDefinition(): ast.FragmentDefinitionNode {
434 let _name: string | undefined;
435 let _condition: string | undefined;
436 if ((_name = advance(nameRe)) == null) throw error('FragmentDefinition');
437 ignored();
438 if (advance(nameRe) !== 'on') throw error('FragmentDefinition');
439 ignored();
440 if ((_condition = advance(nameRe)) == null) throw error('FragmentDefinition');
441 ignored();
442 const _directives = directives(false);
443 if (input.charCodeAt(idx++) !== 123 /*'{'*/) throw error('FragmentDefinition');
444 ignored();
445 return {
446 kind: 'FragmentDefinition' as Kind.FRAGMENT_DEFINITION,
447 name: { kind: 'Name' as Kind.NAME, value: _name },
448 typeCondition: {
449 kind: 'NamedType' as Kind.NAMED_TYPE,
450 name: { kind: 'Name' as Kind.NAME, value: _condition },
451 },
452 directives: _directives,
453 selectionSet: selectionSet(),
454 };
455}
456
457const definitionRe = /(?:query|mutation|subscription|fragment)/y;
458
459function operationDefinition(
460 operation: OperationTypeNode | undefined
461): ast.OperationDefinitionNode | undefined {
462 let _name: string | undefined;
463 let _variableDefinitions: ast.VariableDefinitionNode[] | undefined;
464 let _directives: ast.DirectiveNode[] | undefined;
465 if (operation) {
466 ignored();
467 _name = advance(nameRe);
468 _variableDefinitions = variableDefinitions();
469 _directives = directives(false);
470 }
471 if (input.charCodeAt(idx) === 123 /*'{'*/) {
472 idx++;
473 ignored();
474 return {
475 kind: 'OperationDefinition' as Kind.OPERATION_DEFINITION,
476 operation: operation || ('query' as OperationTypeNode.QUERY),
477 name: _name ? { kind: 'Name' as Kind.NAME, value: _name } : undefined,
478 variableDefinitions: _variableDefinitions,
479 directives: _directives,
480 selectionSet: selectionSet(),
481 };
482 }
483}
484
485function document(): ast.DocumentNode {
486 let match: string | undefined;
487 let definition: ast.OperationDefinitionNode | undefined;
488 ignored();
489 const definitions: ast.ExecutableDefinitionNode[] = [];
490 do {
491 if ((match = advance(definitionRe)) === 'fragment') {
492 ignored();
493 definitions.push(fragmentDefinition());
494 } else if ((definition = operationDefinition(match as OperationTypeNode)) != null) {
495 definitions.push(definition);
496 } else {
497 throw error('Document');
498 }
499 } while (idx < input.length);
500 return {
501 kind: 'Document' as Kind.DOCUMENT,
502 definitions,
503 };
504}
505
506type ParseOptions = {
507 [option: string]: any;
508};
509
510export function parse(
511 string: string | Source,
512 _options?: ParseOptions | undefined
513): ast.DocumentNode {
514 input = typeof string.body === 'string' ? string.body : string;
515 idx = 0;
516 return document();
517}
518
519export function parseValue(
520 string: string | Source,
521 _options?: ParseOptions | undefined
522): ast.ValueNode {
523 input = typeof string.body === 'string' ? string.body : string;
524 idx = 0;
525 ignored();
526 return value(false);
527}
528
529export function parseType(
530 string: string | Source,
531 _options?: ParseOptions | undefined
532): ast.TypeNode {
533 input = typeof string.body === 'string' ? string.body : string;
534 idx = 0;
535 return type();
536}