Mirror: The spec-compliant minimum of client-side GraphQL.

fix: Move unit tests over and fix minor discrepancies (#6)

* Fix missing Kind node and add parser unit tests

* Add printer unit tests

* Add unit tests for visitor

* Add changeset

* Apply lint

* Fix type issues

authored by kitten.sh and committed by

GitHub 06bc2a98 fb366340

+925 -6
+5
.changeset/orange-dryers-count.md
··· 1 + --- 2 + '@0no-co/graphql.web': patch 3 + --- 4 + 5 + Move over unit tests from `graphql-web-lite` and fix minor discrepancies to reference implementation.
+371 -1
src/__tests__/parser.test.ts
··· 2 2 import { readFileSync } from 'fs'; 3 3 4 4 import { parse as graphql_parse } from 'graphql'; 5 - import { parse } from '../parser'; 5 + import { parse, parseType, parseValue } from '../parser'; 6 + import { Kind } from '../kind'; 6 7 7 8 describe('print', () => { 8 9 it('prints the kitchen sink document like graphql.js does', () => { ··· 12 13 const doc = parse(sink); 13 14 expect(doc).toMatchSnapshot(); 14 15 expect(doc).toEqual(graphql_parse(sink, { noLocation: true })); 16 + }); 17 + 18 + it('parse provides errors', () => { 19 + expect(() => parse('{')).toThrow(); 20 + }); 21 + 22 + it('parses variable inline values', () => { 23 + expect(() => { 24 + return parse('{ field(complex: { a: { b: [ $var ] } }) }'); 25 + }).not.toThrow(); 26 + }); 27 + 28 + it('parses constant default values', () => { 29 + expect(() => { 30 + return parse('query Foo($x: Complex = { a: { b: [ "test" ] } }) { field }'); 31 + }).not.toThrow(); 32 + expect(() => { 33 + return parse('query Foo($x: Complex = { a: { b: [ $var ] } }) { field }'); 34 + }).toThrow(); 35 + }); 36 + 37 + it('parses variable definition directives', () => { 38 + expect(() => { 39 + return parse('query Foo($x: Boolean = false @bar) { field }'); 40 + }).not.toThrow(); 41 + }); 42 + 43 + it('does not accept fragments spread of "on"', () => { 44 + expect(() => { 45 + return parse('{ ...on }'); 46 + }).toThrow(); 47 + }); 48 + 49 + it('parses multi-byte characters', () => { 50 + // Note: \u0A0A could be naively interpreted as two line-feed chars. 51 + const ast = parse(` 52 + # This comment has a \u0A0A multi-byte character. 53 + { field(arg: "Has a \u0A0A multi-byte character.") } 54 + `); 55 + 56 + expect(ast).toHaveProperty( 57 + 'definitions.0.selectionSet.selections.0.arguments.0.value.value', 58 + 'Has a \u0A0A multi-byte character.' 59 + ); 60 + }); 61 + 62 + it('parses anonymous mutation operations', () => { 63 + expect(() => { 64 + return parse(` 65 + mutation { 66 + mutationField 67 + } 68 + `); 69 + }).not.toThrow(); 70 + }); 71 + 72 + it('parses anonymous subscription operations', () => { 73 + expect(() => { 74 + return parse(` 75 + subscription { 76 + subscriptionField 77 + } 78 + `); 79 + }).not.toThrow(); 80 + }); 81 + 82 + it('parses named mutation operations', () => { 83 + expect(() => { 84 + return parse(` 85 + mutation Foo { 86 + mutationField 87 + } 88 + `); 89 + }).not.toThrow(); 90 + }); 91 + 92 + it('parses named subscription operations', () => { 93 + expect(() => { 94 + return parse(` 95 + subscription Foo { 96 + subscriptionField 97 + } 98 + `); 99 + }).not.toThrow(); 100 + }); 101 + 102 + it('creates ast', () => { 103 + const result = parse(` 104 + { 105 + node(id: 4) { 106 + id, 107 + name 108 + } 109 + } 110 + `); 111 + 112 + expect(result).toMatchObject({ 113 + kind: Kind.DOCUMENT, 114 + definitions: [ 115 + { 116 + kind: Kind.OPERATION_DEFINITION, 117 + operation: 'query', 118 + name: undefined, 119 + variableDefinitions: [], 120 + directives: [], 121 + selectionSet: { 122 + kind: Kind.SELECTION_SET, 123 + selections: [ 124 + { 125 + kind: Kind.FIELD, 126 + alias: undefined, 127 + name: { 128 + kind: Kind.NAME, 129 + value: 'node', 130 + }, 131 + arguments: [ 132 + { 133 + kind: Kind.ARGUMENT, 134 + name: { 135 + kind: Kind.NAME, 136 + value: 'id', 137 + }, 138 + value: { 139 + kind: Kind.INT, 140 + value: '4', 141 + }, 142 + }, 143 + ], 144 + directives: [], 145 + selectionSet: { 146 + kind: Kind.SELECTION_SET, 147 + selections: [ 148 + { 149 + kind: Kind.FIELD, 150 + alias: undefined, 151 + name: { 152 + kind: Kind.NAME, 153 + value: 'id', 154 + }, 155 + arguments: [], 156 + directives: [], 157 + selectionSet: undefined, 158 + }, 159 + { 160 + kind: Kind.FIELD, 161 + alias: undefined, 162 + name: { 163 + kind: Kind.NAME, 164 + value: 'name', 165 + }, 166 + arguments: [], 167 + directives: [], 168 + selectionSet: undefined, 169 + }, 170 + ], 171 + }, 172 + }, 173 + ], 174 + }, 175 + }, 176 + ], 177 + }); 178 + }); 179 + 180 + it('creates ast from nameless query without variables', () => { 181 + const result = parse(` 182 + query { 183 + node { 184 + id 185 + } 186 + } 187 + `); 188 + 189 + expect(result).toMatchObject({ 190 + kind: Kind.DOCUMENT, 191 + definitions: [ 192 + { 193 + kind: Kind.OPERATION_DEFINITION, 194 + operation: 'query', 195 + name: undefined, 196 + variableDefinitions: [], 197 + directives: [], 198 + selectionSet: { 199 + kind: Kind.SELECTION_SET, 200 + selections: [ 201 + { 202 + kind: Kind.FIELD, 203 + alias: undefined, 204 + name: { 205 + kind: Kind.NAME, 206 + value: 'node', 207 + }, 208 + arguments: [], 209 + directives: [], 210 + selectionSet: { 211 + kind: Kind.SELECTION_SET, 212 + selections: [ 213 + { 214 + kind: Kind.FIELD, 215 + alias: undefined, 216 + name: { 217 + kind: Kind.NAME, 218 + value: 'id', 219 + }, 220 + arguments: [], 221 + directives: [], 222 + selectionSet: undefined, 223 + }, 224 + ], 225 + }, 226 + }, 227 + ], 228 + }, 229 + }, 230 + ], 231 + }); 232 + }); 233 + 234 + it('allows parsing without source location information', () => { 235 + const result = parse('{ id }', { noLocation: true }); 236 + expect('loc' in result).toBe(false); 237 + }); 238 + 239 + describe('parseValue', () => { 240 + it('parses null value', () => { 241 + const result = parseValue('null'); 242 + expect(result).toEqual({ kind: Kind.NULL }); 243 + }); 244 + 245 + it('parses list values', () => { 246 + const result = parseValue('[123 "abc"]'); 247 + expect(result).toEqual({ 248 + kind: Kind.LIST, 249 + values: [ 250 + { 251 + kind: Kind.INT, 252 + value: '123', 253 + }, 254 + { 255 + kind: Kind.STRING, 256 + value: 'abc', 257 + block: false, 258 + }, 259 + ], 260 + }); 261 + }); 262 + 263 + it('parses block strings', () => { 264 + const result = parseValue('["""long""" "short"]'); 265 + expect(result).toEqual({ 266 + kind: Kind.LIST, 267 + values: [ 268 + { 269 + kind: Kind.STRING, 270 + value: 'long', 271 + block: true, 272 + }, 273 + { 274 + kind: Kind.STRING, 275 + value: 'short', 276 + block: false, 277 + }, 278 + ], 279 + }); 280 + }); 281 + 282 + it('allows variables', () => { 283 + const result = parseValue('{ field: $var }'); 284 + expect(result).toEqual({ 285 + kind: Kind.OBJECT, 286 + fields: [ 287 + { 288 + kind: Kind.OBJECT_FIELD, 289 + name: { 290 + kind: Kind.NAME, 291 + value: 'field', 292 + }, 293 + value: { 294 + kind: Kind.VARIABLE, 295 + name: { 296 + kind: Kind.NAME, 297 + value: 'var', 298 + }, 299 + }, 300 + }, 301 + ], 302 + }); 303 + }); 304 + 305 + it('correct message for incomplete variable', () => { 306 + expect(() => { 307 + return parseValue('$'); 308 + }).toThrow(); 309 + }); 310 + 311 + it('correct message for unexpected token', () => { 312 + expect(() => { 313 + return parseValue(':'); 314 + }).toThrow(); 315 + }); 316 + }); 317 + 318 + describe('parseType', () => { 319 + it('parses well known types', () => { 320 + const result = parseType('String'); 321 + expect(result).toEqual({ 322 + kind: Kind.NAMED_TYPE, 323 + name: { 324 + kind: Kind.NAME, 325 + value: 'String', 326 + }, 327 + }); 328 + }); 329 + 330 + it('parses custom types', () => { 331 + const result = parseType('MyType'); 332 + expect(result).toEqual({ 333 + kind: Kind.NAMED_TYPE, 334 + name: { 335 + kind: Kind.NAME, 336 + value: 'MyType', 337 + }, 338 + }); 339 + }); 340 + 341 + it('parses list types', () => { 342 + const result = parseType('[MyType]'); 343 + expect(result).toEqual({ 344 + kind: Kind.LIST_TYPE, 345 + type: { 346 + kind: Kind.NAMED_TYPE, 347 + name: { 348 + kind: Kind.NAME, 349 + value: 'MyType', 350 + }, 351 + }, 352 + }); 353 + }); 354 + 355 + it('parses non-null types', () => { 356 + const result = parseType('MyType!'); 357 + expect(result).toEqual({ 358 + kind: Kind.NON_NULL_TYPE, 359 + type: { 360 + kind: Kind.NAMED_TYPE, 361 + name: { 362 + kind: Kind.NAME, 363 + value: 'MyType', 364 + }, 365 + }, 366 + }); 367 + }); 368 + 369 + it('parses nested types', () => { 370 + const result = parseType('[MyType!]'); 371 + expect(result).toEqual({ 372 + kind: Kind.LIST_TYPE, 373 + type: { 374 + kind: Kind.NON_NULL_TYPE, 375 + type: { 376 + kind: Kind.NAMED_TYPE, 377 + name: { 378 + kind: Kind.NAME, 379 + value: 'MyType', 380 + }, 381 + }, 382 + }, 383 + }); 384 + }); 15 385 }); 16 386 });
+96 -1
src/__tests__/printer.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 2 import { readFileSync } from 'fs'; 3 3 4 - import { print as graphql_print } from 'graphql'; 4 + import { parse, print as graphql_print } from 'graphql'; 5 5 import { print } from '../printer'; 6 6 7 + function dedentString(string) { 8 + const trimmedStr = string 9 + .replace(/^\n*/m, '') // remove leading newline 10 + .replace(/[ \t\n]*$/, ''); // remove trailing spaces and tabs 11 + // fixes indentation by removing leading spaces and tabs from each line 12 + let indent = ''; 13 + for (const char of trimmedStr) { 14 + if (char !== ' ' && char !== '\t') { 15 + break; 16 + } 17 + indent += char; 18 + } 19 + 20 + return trimmedStr.replace(RegExp('^' + indent, 'mg'), ''); // remove indent 21 + } 22 + 23 + function dedent(strings, ...values) { 24 + let str = strings[0]; 25 + for (let i = 1; i < strings.length; ++i) str += values[i - 1] + strings[i]; // interpolation 26 + return dedentString(str); 27 + } 28 + 7 29 describe('print', () => { 8 30 it('prints the kitchen sink document like graphql.js does', () => { 9 31 const sink = JSON.parse(readFileSync(__dirname + '/kitchen_sink.json', { encoding: 'utf8' })); 10 32 const doc = print(sink); 11 33 expect(doc).toMatchSnapshot(); 12 34 expect(doc).toEqual(graphql_print(sink)); 35 + }); 36 + 37 + it('prints minimal ast', () => { 38 + const ast = { 39 + kind: 'Field', 40 + name: { kind: 'Name', value: 'foo' }, 41 + }; 42 + expect(print(ast as any)).toBe('foo'); 43 + }); 44 + 45 + // NOTE: The shim won't throw for invalid AST nodes 46 + it('returns empty strings for invalid AST', () => { 47 + const badAST = { random: 'Data' }; 48 + expect(print(badAST as any)).toBe(''); 49 + }); 50 + 51 + it('correctly prints non-query operations without name', () => { 52 + const queryASTShorthanded = parse('query { id, name }'); 53 + expect(print(queryASTShorthanded)).toBe(dedent` 54 + { 55 + id 56 + name 57 + } 58 + `); 59 + 60 + const mutationAST = parse('mutation { id, name }'); 61 + expect(print(mutationAST)).toBe(dedent` 62 + mutation { 63 + id 64 + name 65 + } 66 + `); 67 + 68 + const queryASTWithArtifacts = parse('query ($foo: TestType) @testDirective { id, name }'); 69 + expect(print(queryASTWithArtifacts)).toBe(dedent` 70 + query ($foo: TestType) @testDirective { 71 + id 72 + name 73 + } 74 + `); 75 + 76 + const mutationASTWithArtifacts = parse('mutation ($foo: TestType) @testDirective { id, name }'); 77 + expect(print(mutationASTWithArtifacts)).toBe(dedent` 78 + mutation ($foo: TestType) @testDirective { 79 + id 80 + name 81 + } 82 + `); 83 + }); 84 + 85 + it('prints query with variable directives', () => { 86 + const queryASTWithVariableDirective = parse( 87 + 'query ($foo: TestType = {a: 123} @testDirective(if: true) @test) { id }' 88 + ); 89 + expect(print(queryASTWithVariableDirective)).toBe(dedent` 90 + query ($foo: TestType = {a: 123} @testDirective(if: true) @test) { 91 + id 92 + } 93 + `); 94 + }); 95 + 96 + it('keeps arguments on one line if line is short (<= 80 chars)', () => { 97 + const printed = print(parse('{trip(wheelchair:false arriveBy:false){dateTime}}')); 98 + 99 + expect(printed).toBe( 100 + dedent` 101 + { 102 + trip(wheelchair: false, arriveBy: false) { 103 + dateTime 104 + } 105 + } 106 + ` 107 + ); 13 108 }); 14 109 });
+437
src/__tests__/visitor.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { Kind, parse, print } from 'graphql'; 3 + import { visit, BREAK } from '../visitor'; 4 + 5 + function checkVisitorFnArgs(ast, args, isEdited = false) { 6 + const [node, key, parent, path, ancestors] = args; 7 + 8 + expect(node).toBeInstanceOf(Object); 9 + expect(Object.values(Kind)).toContain(node.kind); 10 + 11 + const isRoot = key === undefined; 12 + if (isRoot) { 13 + if (!isEdited) { 14 + expect(node).toEqual(ast); 15 + } 16 + expect(parent).toEqual(undefined); 17 + expect(path).toEqual([]); 18 + expect(ancestors).toEqual([]); 19 + return; 20 + } 21 + 22 + expect(typeof key).toMatch(/number|string/); 23 + 24 + expect(parent).toHaveProperty([key]); 25 + 26 + expect(path).toBeInstanceOf(Array); 27 + expect(path[path.length - 1]).toEqual(key); 28 + 29 + expect(ancestors).toBeInstanceOf(Array); 30 + expect(ancestors.length).toEqual(path.length - 1); 31 + 32 + if (!isEdited) { 33 + let currentNode = ast; 34 + for (let i = 0; i < ancestors.length; ++i) { 35 + expect(ancestors[i]).toEqual(currentNode); 36 + 37 + currentNode = currentNode[path[i]]; 38 + expect(currentNode).not.toEqual(undefined); 39 + } 40 + } 41 + } 42 + 43 + function getValue(node: any) { 44 + return 'value' in node ? node.value : undefined; 45 + } 46 + 47 + describe('Visitor', () => { 48 + it('handles empty visitor', () => { 49 + const ast = parse('{ a }', { noLocation: true }); 50 + expect(() => visit(ast, {})).not.toThrow(); 51 + }); 52 + 53 + it('validates path argument', () => { 54 + const visited: any[] = []; 55 + 56 + const ast = parse('{ a }', { noLocation: true }); 57 + 58 + visit(ast, { 59 + enter(_node, _key, _parent, path) { 60 + checkVisitorFnArgs(ast, arguments); 61 + visited.push(['enter', path.slice()]); 62 + }, 63 + leave(_node, _key, _parent, path) { 64 + checkVisitorFnArgs(ast, arguments); 65 + visited.push(['leave', path.slice()]); 66 + }, 67 + }); 68 + 69 + expect(visited).toEqual([ 70 + ['enter', []], 71 + ['enter', ['definitions', 0]], 72 + ['enter', ['definitions', 0, 'selectionSet']], 73 + ['enter', ['definitions', 0, 'selectionSet', 'selections', 0]], 74 + ['enter', ['definitions', 0, 'selectionSet', 'selections', 0, 'name']], 75 + ['leave', ['definitions', 0, 'selectionSet', 'selections', 0, 'name']], 76 + ['leave', ['definitions', 0, 'selectionSet', 'selections', 0]], 77 + ['leave', ['definitions', 0, 'selectionSet']], 78 + ['leave', ['definitions', 0]], 79 + ['leave', []], 80 + ]); 81 + }); 82 + 83 + it('validates ancestors argument', () => { 84 + const ast = parse('{ a }', { noLocation: true }); 85 + const visitedNodes: any[] = []; 86 + 87 + visit(ast, { 88 + enter(node, key, parent, _path, ancestors) { 89 + const inArray = typeof key === 'number'; 90 + if (inArray) { 91 + visitedNodes.push(parent); 92 + } 93 + visitedNodes.push(node); 94 + 95 + const expectedAncestors = visitedNodes.slice(0, -2); 96 + expect(ancestors).toEqual(expectedAncestors); 97 + }, 98 + leave(_node, key, _parent, _path, ancestors) { 99 + const expectedAncestors = visitedNodes.slice(0, -2); 100 + expect(ancestors).toEqual(expectedAncestors); 101 + 102 + const inArray = typeof key === 'number'; 103 + if (inArray) { 104 + visitedNodes.pop(); 105 + } 106 + visitedNodes.pop(); 107 + }, 108 + }); 109 + }); 110 + 111 + it('allows editing a node both on enter and on leave', () => { 112 + const ast = parse('{ a, b, c { a, b, c } }', { noLocation: true }); 113 + 114 + let selectionSet; 115 + 116 + const editedAST = visit(ast, { 117 + OperationDefinition: { 118 + enter(node) { 119 + checkVisitorFnArgs(ast, arguments); 120 + selectionSet = node.selectionSet; 121 + return { 122 + ...node, 123 + selectionSet: { 124 + kind: 'SelectionSet', 125 + selections: [], 126 + }, 127 + didEnter: true, 128 + }; 129 + }, 130 + leave(node) { 131 + checkVisitorFnArgs(ast, arguments, /* isEdited */ true); 132 + return { 133 + ...node, 134 + selectionSet, 135 + didLeave: true, 136 + }; 137 + }, 138 + }, 139 + }); 140 + 141 + expect(editedAST).toEqual({ 142 + ...ast, 143 + definitions: [ 144 + { 145 + ...ast.definitions[0], 146 + didEnter: true, 147 + didLeave: true, 148 + }, 149 + ], 150 + }); 151 + }); 152 + 153 + it('allows editing the root node on enter and on leave', () => { 154 + const ast = parse('{ a, b, c { a, b, c } }', { noLocation: true }); 155 + 156 + const { definitions } = ast; 157 + 158 + const editedAST = visit(ast, { 159 + Document: { 160 + enter(node) { 161 + checkVisitorFnArgs(ast, arguments); 162 + return { 163 + ...node, 164 + definitions: [], 165 + didEnter: true, 166 + }; 167 + }, 168 + leave(node) { 169 + checkVisitorFnArgs(ast, arguments, /* isEdited */ true); 170 + return { 171 + ...node, 172 + definitions, 173 + didLeave: true, 174 + }; 175 + }, 176 + }, 177 + }); 178 + 179 + expect(editedAST).toEqual({ 180 + ...ast, 181 + didEnter: true, 182 + didLeave: true, 183 + }); 184 + }); 185 + 186 + it('allows for editing on enter', () => { 187 + const ast = parse('{ a, b, c { a, b, c } }', { noLocation: true }); 188 + const editedAST = visit(ast, { 189 + enter(node) { 190 + checkVisitorFnArgs(ast, arguments); 191 + if (node.kind === 'Field' && node.name.value === 'b') { 192 + return null; 193 + } 194 + }, 195 + }); 196 + 197 + expect(ast).toEqual(parse('{ a, b, c { a, b, c } }', { noLocation: true })); 198 + 199 + expect(editedAST).toEqual(parse('{ a, c { a, c } }', { noLocation: true })); 200 + }); 201 + 202 + it('allows for editing on leave', () => { 203 + const ast = parse('{ a, b, c { a, b, c } }', { noLocation: true }); 204 + const editedAST = visit(ast, { 205 + leave(node) { 206 + checkVisitorFnArgs(ast, arguments, /* isEdited */ true); 207 + if (node.kind === 'Field' && node.name.value === 'b') { 208 + return null; 209 + } 210 + }, 211 + }); 212 + 213 + expect(ast).toEqual(parse('{ a, b, c { a, b, c } }', { noLocation: true })); 214 + 215 + expect(editedAST).toEqual(parse('{ a, c { a, c } }', { noLocation: true })); 216 + }); 217 + 218 + it('ignores false returned on leave', () => { 219 + const ast = parse('{ a, b, c { a, b, c } }', { noLocation: true }); 220 + const returnedAST = visit(ast, { 221 + leave() { 222 + return false; 223 + }, 224 + }); 225 + 226 + expect(returnedAST).toEqual(parse('{ a, b, c { a, b, c } }', { noLocation: true })); 227 + }); 228 + 229 + it('visits edited node', () => { 230 + const addedField = { 231 + kind: 'Field', 232 + name: { 233 + kind: 'Name', 234 + value: '__typename', 235 + }, 236 + }; 237 + 238 + let didVisitAddedField; 239 + 240 + const ast = parse('{ a { x } }', { noLocation: true }); 241 + visit(ast, { 242 + enter(node) { 243 + checkVisitorFnArgs(ast, arguments, /* isEdited */ true); 244 + if (node.kind === 'Field' && node.name.value === 'a') { 245 + return { 246 + kind: 'Field', 247 + selectionSet: [addedField, node.selectionSet], 248 + }; 249 + } 250 + if (node === addedField) { 251 + didVisitAddedField = true; 252 + } 253 + }, 254 + }); 255 + 256 + expect(didVisitAddedField).toEqual(true); 257 + }); 258 + 259 + it('allows skipping a sub-tree', () => { 260 + const visited: any[] = []; 261 + 262 + const ast = parse('{ a, b { x }, c }', { noLocation: true }); 263 + visit(ast, { 264 + enter(node) { 265 + checkVisitorFnArgs(ast, arguments); 266 + visited.push(['enter', node.kind, getValue(node)]); 267 + if (node.kind === 'Field' && node.name.value === 'b') { 268 + return false; 269 + } 270 + }, 271 + 272 + leave(node) { 273 + checkVisitorFnArgs(ast, arguments); 274 + visited.push(['leave', node.kind, getValue(node)]); 275 + }, 276 + }); 277 + 278 + expect(visited).toEqual([ 279 + ['enter', 'Document', undefined], 280 + ['enter', 'OperationDefinition', undefined], 281 + ['enter', 'SelectionSet', undefined], 282 + ['enter', 'Field', undefined], 283 + ['enter', 'Name', 'a'], 284 + ['leave', 'Name', 'a'], 285 + ['leave', 'Field', undefined], 286 + ['enter', 'Field', undefined], 287 + ['enter', 'Field', undefined], 288 + ['enter', 'Name', 'c'], 289 + ['leave', 'Name', 'c'], 290 + ['leave', 'Field', undefined], 291 + ['leave', 'SelectionSet', undefined], 292 + ['leave', 'OperationDefinition', undefined], 293 + ['leave', 'Document', undefined], 294 + ]); 295 + }); 296 + 297 + it('allows early exit while visiting', () => { 298 + const visited: any[] = []; 299 + 300 + const ast = parse('{ a, b { x }, c }', { noLocation: true }); 301 + visit(ast, { 302 + enter(node) { 303 + checkVisitorFnArgs(ast, arguments); 304 + visited.push(['enter', node.kind, getValue(node)]); 305 + if (node.kind === 'Name' && node.value === 'x') { 306 + return BREAK; 307 + } 308 + }, 309 + leave(node) { 310 + checkVisitorFnArgs(ast, arguments); 311 + visited.push(['leave', node.kind, getValue(node)]); 312 + }, 313 + }); 314 + 315 + expect(visited).toEqual([ 316 + ['enter', 'Document', undefined], 317 + ['enter', 'OperationDefinition', undefined], 318 + ['enter', 'SelectionSet', undefined], 319 + ['enter', 'Field', undefined], 320 + ['enter', 'Name', 'a'], 321 + ['leave', 'Name', 'a'], 322 + ['leave', 'Field', undefined], 323 + ['enter', 'Field', undefined], 324 + ['enter', 'Name', 'b'], 325 + ['leave', 'Name', 'b'], 326 + ['enter', 'SelectionSet', undefined], 327 + ['enter', 'Field', undefined], 328 + ['enter', 'Name', 'x'], 329 + ]); 330 + }); 331 + 332 + it('allows early exit while leaving', () => { 333 + const visited: any[] = []; 334 + 335 + const ast = parse('{ a, b { x }, c }', { noLocation: true }); 336 + visit(ast, { 337 + enter(node) { 338 + checkVisitorFnArgs(ast, arguments); 339 + visited.push(['enter', node.kind, getValue(node)]); 340 + }, 341 + 342 + leave(node) { 343 + checkVisitorFnArgs(ast, arguments); 344 + visited.push(['leave', node.kind, getValue(node)]); 345 + if (node.kind === 'Name' && node.value === 'x') { 346 + return BREAK; 347 + } 348 + }, 349 + }); 350 + 351 + expect(visited).toEqual([ 352 + ['enter', 'Document', undefined], 353 + ['enter', 'OperationDefinition', undefined], 354 + ['enter', 'SelectionSet', undefined], 355 + ['enter', 'Field', undefined], 356 + ['enter', 'Name', 'a'], 357 + ['leave', 'Name', 'a'], 358 + ['leave', 'Field', undefined], 359 + ['enter', 'Field', undefined], 360 + ['enter', 'Name', 'b'], 361 + ['leave', 'Name', 'b'], 362 + ['enter', 'SelectionSet', undefined], 363 + ['enter', 'Field', undefined], 364 + ['enter', 'Name', 'x'], 365 + ['leave', 'Name', 'x'], 366 + ]); 367 + }); 368 + 369 + it('allows a named functions visitor API', () => { 370 + const visited: any[] = []; 371 + 372 + const ast = parse('{ a, b { x }, c }', { noLocation: true }); 373 + visit(ast, { 374 + Name(node) { 375 + checkVisitorFnArgs(ast, arguments); 376 + visited.push(['enter', node.kind, getValue(node)]); 377 + }, 378 + SelectionSet: { 379 + enter(node) { 380 + checkVisitorFnArgs(ast, arguments); 381 + visited.push(['enter', node.kind, getValue(node)]); 382 + }, 383 + leave(node) { 384 + checkVisitorFnArgs(ast, arguments); 385 + visited.push(['leave', node.kind, getValue(node)]); 386 + }, 387 + }, 388 + }); 389 + 390 + expect(visited).toEqual([ 391 + ['enter', 'SelectionSet', undefined], 392 + ['enter', 'Name', 'a'], 393 + ['enter', 'Name', 'b'], 394 + ['enter', 'SelectionSet', undefined], 395 + ['enter', 'Name', 'x'], 396 + ['leave', 'SelectionSet', undefined], 397 + ['enter', 'Name', 'c'], 398 + ['leave', 'SelectionSet', undefined], 399 + ]); 400 + }); 401 + 402 + it('handles deep immutable edits correctly when using "enter"', () => { 403 + const formatNode = node => { 404 + if ( 405 + node.selectionSet && 406 + !node.selectionSet.selections.some( 407 + node => node.kind === Kind.FIELD && node.name.value === '__typename' && !node.alias 408 + ) 409 + ) { 410 + return { 411 + ...node, 412 + selectionSet: { 413 + ...node.selectionSet, 414 + selections: [ 415 + ...node.selectionSet.selections, 416 + { 417 + kind: Kind.FIELD, 418 + name: { 419 + kind: Kind.NAME, 420 + value: '__typename', 421 + }, 422 + }, 423 + ], 424 + }, 425 + }; 426 + } 427 + }; 428 + const ast = parse('{ players { nodes { id } } }'); 429 + const expected = parse('{ players { nodes { id __typename } __typename } }'); 430 + const visited = visit(ast, { 431 + Field: formatNode, 432 + InlineFragment: formatNode, 433 + }); 434 + 435 + expect(print(visited)).toEqual(print(expected)); 436 + }); 437 + });
+7
src/kind.js
··· 10 10 INLINE_FRAGMENT: 'InlineFragment', 11 11 FRAGMENT_DEFINITION: 'FragmentDefinition', 12 12 VARIABLE: 'Variable', 13 + INT: 'IntValue', 14 + FLOAT: 'FloatValue', 15 + STRING: 'StringValue', 16 + BOOLEAN: 'BooleanValue', 17 + NULL: 'NullValue', 18 + ENUM: 'EnumValue', 19 + LIST: 'ListValue', 13 20 OBJECT: 'ObjectValue', 14 21 OBJECT_FIELD: 'ObjectField', 15 22 DIRECTIVE: 'Directive',
+2 -2
src/parser.ts
··· 26 26 27 27 const leadingRe = / +(?=[^\s])/y; 28 28 export function blockString(string: string) { 29 + const lines = string.split('\n'); 29 30 let out = ''; 30 31 let commonIndent = 0; 31 32 let firstNonEmptyLine = 0; 32 - let lastNonEmptyLine = -1; 33 - const lines = string.split('\n'); 33 + let lastNonEmptyLine = lines.length - 1; 34 34 for (let i = 0; i < lines.length; i++) { 35 35 leadingRe.lastIndex = 0; 36 36 if (leadingRe.test(lines[i])) {
+7 -2
src/visitor.ts
··· 16 16 ) { 17 17 let hasEdited = false; 18 18 19 - const enter = (visitor[node.kind] && visitor[node.kind].enter) || visitor[node.kind]; 19 + const enter = 20 + (visitor[node.kind] && visitor[node.kind].enter) || 21 + visitor[node.kind] || 22 + (visitor as EnterLeaveVisitor<ASTNode>).enter; 20 23 const resultEnter = enter && enter.call(visitor, node, key, parent, path, ancestors); 21 24 if (resultEnter === false) { 22 25 return node; ··· 69 72 } 70 73 71 74 if (parent) ancestors.pop(); 72 - const leave = visitor[node.kind] && visitor[node.kind].leave; 75 + const leave = 76 + (visitor[node.kind] && visitor[node.kind].leave) || 77 + (visitor as EnterLeaveVisitor<ASTNode>).leave; 73 78 const resultLeave = leave && leave.call(visitor, node, key, parent, path, ancestors); 74 79 if (resultLeave === BREAK) { 75 80 throw BREAK;