From fbcb2ce5a4d2a7583282d5f534694b2f37118788 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Wed, 31 Jul 2024 12:14:52 -0700 Subject: [PATCH 01/11] Add tests for parsing schema extensions --- .../src/parser/__tests__/OnlineParser.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts index aec3609509f..f959eee76b8 100644 --- a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts +++ b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts @@ -67,6 +67,49 @@ describe('onlineParser', () => { t.eol(); }); + + it('parses schema extension bare', () => { + const { t } = getUtils(` + extend schema + `); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('schema', { kind: 'SchemaDef' }); + + t.eol(); + }); + + it('parses schema extension with operation defs', () => { + const { t } = getUtils(` + extend schema { + query: SomeType + } + `); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('schema', { kind: 'SchemaDef' }); + t.punctuation('{'); + + t.keyword('query', { kind: 'OperationTypeDef' }); + t.punctuation(':'); + t.name('SomeType'); + + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); + + it('parses schema extension with directive applications', () => { + const { t } = getUtils(` + extend schema @someDirective + `); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('schema', { kind: 'SchemaDef' }); + expectDirective({ t }, { name: 'someDirective' }); + + t.eol(); + }); it('parses short query', () => { const { t } = getUtils(` From 7a4b468a1e03df4ef0b74d2f961652e1cc3b5fea Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Wed, 31 Jul 2024 13:50:57 -0700 Subject: [PATCH 02/11] Add a failing test --- .../src/parser/__tests__/OnlineParser.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts index f959eee76b8..e591b84cb3b 100644 --- a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts +++ b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts @@ -67,7 +67,7 @@ describe('onlineParser', () => { t.eol(); }); - + it('parses schema extension bare', () => { const { t } = getUtils(` extend schema @@ -111,6 +111,28 @@ describe('onlineParser', () => { t.eol(); }); + it('parses schema extension with directive applications without root operation definitions, followed by a type definition', () => { + const { t } = getUtils(` + extend schema @someDirective + + type A { field: String } + `); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('schema', { kind: 'SchemaDef' }); + expectDirective({ t }, { name: 'someDirective' }); + + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('A'); + t.punctuation('{'); + t.property('field', { kind: 'FieldDef' }); + t.punctuation(':'); + t.name('String', { kind: 'NamedType' }); + t.punctuation('}'); + + t.eol(); + }); + it('parses short query', () => { const { t } = getUtils(` { From 0931d14aeecee4b8898ed749a3546a9531228906 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Mon, 30 Sep 2024 12:56:59 -0700 Subject: [PATCH 03/11] Fix parser and update tests --- .../graphql-language-service/src/parser/Rules.ts | 16 +++++++--------- .../src/parser/__tests__/OnlineParser.test.ts | 14 +++++++------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/graphql-language-service/src/parser/Rules.ts b/packages/graphql-language-service/src/parser/Rules.ts index af74ca23823..59dcdaddfee 100644 --- a/packages/graphql-language-service/src/parser/Rules.ts +++ b/packages/graphql-language-service/src/parser/Rules.ts @@ -239,14 +239,8 @@ export const ParseRules: { [name: string]: ParseRule } = { Implements: [word('implements'), list('NamedType', p('&'))], DirectiveLocation: [name('string-2')], // GraphQL schema language - SchemaDef: [ - word('schema'), - list('Directive'), - p('{'), - list('OperationTypeDef'), - p('}'), - ], - + SchemaDef: [word('schema'), list('Directive'), 'OperationTypeDefs'], + OperationTypeDefs: [p('{'), list('OperationTypeDef'), p('}')], OperationTypeDef: [name('keyword'), p(':'), name('atom')], ScalarDef: [word('scalar'), name('atom'), list('Directive')], ObjectTypeDef: [ @@ -322,7 +316,11 @@ export const ParseRules: { [name: string]: ParseRule } = { return Kind.INPUT_OBJECT_TYPE_EXTENSION; } }, - [Kind.SCHEMA_EXTENSION]: ['SchemaDef'], + [Kind.SCHEMA_EXTENSION]: [ + word('schema'), + list('Directive'), + opt('OperationTypeDefs'), + ], [Kind.SCALAR_TYPE_EXTENSION]: ['ScalarDef'], [Kind.OBJECT_TYPE_EXTENSION]: ['ObjectTypeDef'], [Kind.INTERFACE_TYPE_EXTENSION]: ['InterfaceDef'], diff --git a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts index e591b84cb3b..e6f4f326dec 100644 --- a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts +++ b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts @@ -57,7 +57,7 @@ describe('onlineParser', () => { `); t.keyword('schema', { kind: 'SchemaDef' }); - t.punctuation('{'); + t.punctuation('{', { kind: 'OperationTypeDefs' }); t.keyword('query', { kind: 'OperationTypeDef' }); t.punctuation(':'); @@ -74,7 +74,7 @@ describe('onlineParser', () => { `); t.keyword('extend', { kind: 'ExtendDef' }); - t.keyword('schema', { kind: 'SchemaDef' }); + t.keyword('schema', { kind: 'SchemaExtension' }); t.eol(); }); @@ -87,8 +87,8 @@ describe('onlineParser', () => { `); t.keyword('extend', { kind: 'ExtendDef' }); - t.keyword('schema', { kind: 'SchemaDef' }); - t.punctuation('{'); + t.keyword('schema', { kind: 'SchemaExtension' }); + t.punctuation('{', { kind: 'OperationTypeDefs' }); t.keyword('query', { kind: 'OperationTypeDef' }); t.punctuation(':'); @@ -105,7 +105,7 @@ describe('onlineParser', () => { `); t.keyword('extend', { kind: 'ExtendDef' }); - t.keyword('schema', { kind: 'SchemaDef' }); + t.keyword('schema', { kind: 'SchemaExtension' }); expectDirective({ t }, { name: 'someDirective' }); t.eol(); @@ -119,7 +119,7 @@ describe('onlineParser', () => { `); t.keyword('extend', { kind: 'ExtendDef' }); - t.keyword('schema', { kind: 'SchemaDef' }); + t.keyword('schema', { kind: 'SchemaExtension' }); expectDirective({ t }, { name: 'someDirective' }); t.keyword('type', { kind: 'ObjectTypeDef' }); @@ -128,7 +128,7 @@ describe('onlineParser', () => { t.property('field', { kind: 'FieldDef' }); t.punctuation(':'); t.name('String', { kind: 'NamedType' }); - t.punctuation('}'); + t.punctuation('}', { kind: 'Document' }); t.eol(); }); From 9149e16dc2682486b582c20c206f8bf3eff5a443 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Mon, 30 Sep 2024 13:03:55 -0700 Subject: [PATCH 04/11] changeset --- .changeset/afraid-oranges-clean.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/afraid-oranges-clean.md diff --git a/.changeset/afraid-oranges-clean.md b/.changeset/afraid-oranges-clean.md new file mode 100644 index 00000000000..2048c644bfa --- /dev/null +++ b/.changeset/afraid-oranges-clean.md @@ -0,0 +1,9 @@ +--- +'graphql-language-service': patch +--- + +fix: Correctly parse schema extensions with no root operations + +Previously, the parser gave schema extensions the same treatment as schema definitions. The requirements are slightly different, however, since a schema extension does not require a list of root operations according to the spec: https://spec.graphql.org/draft/#sec-Schema-Extension. + +The rule for parsing a schema extension is now distinct from schema definition, allowing the root operations list to be omitted. From 7651d6e121ac3980aed0912b64ccfa256645d273 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Mon, 2 Dec 2024 14:55:48 -0800 Subject: [PATCH 05/11] Update .changeset/afraid-oranges-clean.md Co-authored-by: Ted Thibodeau Jr --- .changeset/afraid-oranges-clean.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/afraid-oranges-clean.md b/.changeset/afraid-oranges-clean.md index 2048c644bfa..417922cf249 100644 --- a/.changeset/afraid-oranges-clean.md +++ b/.changeset/afraid-oranges-clean.md @@ -6,4 +6,4 @@ fix: Correctly parse schema extensions with no root operations Previously, the parser gave schema extensions the same treatment as schema definitions. The requirements are slightly different, however, since a schema extension does not require a list of root operations according to the spec: https://spec.graphql.org/draft/#sec-Schema-Extension. -The rule for parsing a schema extension is now distinct from schema definition, allowing the root operations list to be omitted. +The rule for parsing a schema extension is now distinct from that for a schema definition, allowing the root operations list to be omitted. From c34d2b9b920755c09aa3e817dc05c4eb62fa2777 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Wed, 6 May 2026 12:49:08 -0700 Subject: [PATCH 06/11] Allow optional fields body in `type` and `extend type` Per the GraphQL spec, both `ObjectTypeDefinition` and `ObjectTypeExtension` permit omitting the `{ FieldDefinition+ }` body: - https://spec.graphql.org/draft/#sec-Objects - https://spec.graphql.org/draft/#sec-Object-Extensions Previously the parser required the body in `ObjectTypeDef`, which made forms like `type Foo @directive` fail to parse cleanly when followed by another definition. Extract the body into a new `FieldDefs` rule and make it optional. `[Kind.OBJECT_TYPE_EXTENSION]` continues to reuse `ObjectTypeDef` and inherits the relaxed grammar. --- .../src/parser/Rules.ts | 5 +- .../src/parser/__tests__/OnlineParser.test.ts | 63 ++++++++++++++++--- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/graphql-language-service/src/parser/Rules.ts b/packages/graphql-language-service/src/parser/Rules.ts index 59dcdaddfee..e2bc89f4664 100644 --- a/packages/graphql-language-service/src/parser/Rules.ts +++ b/packages/graphql-language-service/src/parser/Rules.ts @@ -248,10 +248,9 @@ export const ParseRules: { [name: string]: ParseRule } = { name('atom'), opt('Implements'), list('Directive'), - p('{'), - list('FieldDef'), - p('}'), + opt('FieldDefs'), ], + FieldDefs: [p('{'), list('FieldDef'), p('}')], FieldDef: [ name('property'), diff --git a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts index e6f4f326dec..420ab961dab 100644 --- a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts +++ b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts @@ -124,7 +124,7 @@ describe('onlineParser', () => { t.keyword('type', { kind: 'ObjectTypeDef' }); t.name('A'); - t.punctuation('{'); + t.punctuation('{', { kind: 'FieldDefs' }); t.property('field', { kind: 'FieldDef' }); t.punctuation(':'); t.name('String', { kind: 'NamedType' }); @@ -973,7 +973,7 @@ describe('onlineParser', () => { `); t.keyword('type', { kind: 'ObjectTypeDef' }); t.name('SomeType'); - t.punctuation('{'); + t.punctuation('{', { kind: 'FieldDefs' }); t.property('someField', { kind: 'FieldDef' }); t.punctuation(':'); @@ -985,6 +985,28 @@ describe('onlineParser', () => { t.eol(); }); + it('with no fields body, followed by another definition', () => { + const { t } = getUtils(` + type SomeType @someDirective + + type AnotherType { field: String } + `); + + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('SomeType'); + expectDirective({ t }, { name: 'someDirective' }); + + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('AnotherType'); + t.punctuation('{', { kind: 'FieldDefs' }); + t.property('field', { kind: 'FieldDef' }); + t.punctuation(':'); + t.name('String', { kind: 'NamedType' }); + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); + it('with an object implementing an interface', () => { const { t } = getUtils('type SomeType implements SomeInterface'); @@ -1120,7 +1142,7 @@ describe('onlineParser', () => { `); t.keyword('type', { kind: 'ObjectTypeDef' }); t.name('SomeType'); - t.punctuation('{'); + t.punctuation('{', { kind: 'FieldDefs' }); t.property('someField', { kind: 'FieldDef' }); t.punctuation(':'); @@ -1140,7 +1162,7 @@ describe('onlineParser', () => { `); t.keyword('type', { kind: 'ObjectTypeDef' }); t.name('SomeType'); - t.punctuation('{'); + t.punctuation('{', { kind: 'FieldDefs' }); t.property('someField', { kind: 'FieldDef' }); t.punctuation(/\(/, { kind: 'ArgumentsDef' }); @@ -1169,7 +1191,7 @@ describe('onlineParser', () => { t.keyword('type', { kind: 'ObjectTypeDef' }); t.name('SomeType'); - t.punctuation('{'); + t.punctuation('{', { kind: 'FieldDefs' }); t.property('someField', { kind: 'FieldDef' }); t.punctuation(':'); @@ -1190,7 +1212,7 @@ describe('onlineParser', () => { t.keyword('type', { kind: 'ObjectTypeDef' }); t.name('SomeType'); - t.punctuation('{'); + t.punctuation('{', { kind: 'FieldDefs' }); t.property('someField', { kind: 'FieldDef' }); t.punctuation(':'); @@ -1213,7 +1235,7 @@ describe('onlineParser', () => { it(`with a directive having arguments of type ${fill.type}`, () => { t.keyword('type', { kind: 'ObjectTypeDef' }); t.name('SomeType'); - t.punctuation('{'); + t.punctuation('{', { kind: 'FieldDefs' }); t.property('someField', { kind: 'FieldDef' }); t.punctuation(':'); @@ -1246,7 +1268,7 @@ describe('onlineParser', () => { t.keyword('extend', { kind: 'ExtendDef' }); t.keyword('type', { kind: 'ObjectTypeDef' }); t.name('SomeType'); - t.punctuation('{'); + t.punctuation('{', { kind: 'FieldDefs' }); t.property('someField', { kind: 'FieldDef' }); t.punctuation(':'); @@ -1267,7 +1289,7 @@ describe('onlineParser', () => { t.keyword('extend', { kind: 'ExtendDef' }); t.keyword('type', { kind: 'ObjectTypeDef' }); t.name('SomeType'); - t.punctuation('{'); + t.punctuation('{', { kind: 'FieldDefs' }); t.property('someField', { kind: 'FieldDef' }); t.punctuation(':'); @@ -1279,6 +1301,29 @@ describe('onlineParser', () => { t.eol(); }); + + it('with no fields body, followed by another definition', () => { + const { t } = getUtils(` + extend type SomeType @someDirective + + type AnotherType { field: String } + `); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('SomeType'); + expectDirective({ t }, { name: 'someDirective' }); + + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('AnotherType'); + t.punctuation('{', { kind: 'FieldDefs' }); + t.property('field', { kind: 'FieldDef' }); + t.punctuation(':'); + t.name('String', { kind: 'NamedType' }); + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); }); describe('parses input type def', () => { From 7dfee7612b89d6b899459be2b1964330ac527fab Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Wed, 6 May 2026 12:50:06 -0700 Subject: [PATCH 07/11] Allow optional fields body in `interface` and `extend interface` Per the GraphQL spec, both `InterfaceTypeDefinition` and `InterfaceTypeExtension` permit omitting the `{ FieldDefinition+ }` body: - https://spec.graphql.org/draft/#sec-Interfaces - https://spec.graphql.org/draft/#sec-Interface-Extensions Mark the body as optional in `InterfaceDef` (sharing the new `FieldDefs` rule with `ObjectTypeDef`). `[Kind.INTERFACE_TYPE_EXTENSION]` continues to reuse `InterfaceDef` and inherits the relaxed grammar. --- .../src/parser/Rules.ts | 4 +- .../src/parser/__tests__/OnlineParser.test.ts | 70 ++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/graphql-language-service/src/parser/Rules.ts b/packages/graphql-language-service/src/parser/Rules.ts index e2bc89f4664..c80a1fe5db5 100644 --- a/packages/graphql-language-service/src/parser/Rules.ts +++ b/packages/graphql-language-service/src/parser/Rules.ts @@ -232,9 +232,7 @@ export const ParseRules: { [name: string]: ParseRule } = { name('atom'), opt('Implements'), list('Directive'), - p('{'), - list('FieldDef'), - p('}'), + opt('FieldDefs'), ], Implements: [word('implements'), list('NamedType', p('&'))], DirectiveLocation: [name('string-2')], diff --git a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts index 420ab961dab..4fde4ab7b38 100644 --- a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts +++ b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts @@ -1074,7 +1074,7 @@ describe('onlineParser', () => { `); t.keyword('interface', { kind: 'InterfaceDef' }); t.name('SomeInterface'); - t.punctuation('{'); + t.punctuation('{', { kind: 'FieldDefs' }); t.property('someField', { kind: 'FieldDef' }); t.punctuation(':'); @@ -1086,6 +1086,28 @@ describe('onlineParser', () => { t.eol(); }); + it('with no fields body, followed by another definition', () => { + const { t } = getUtils(` + interface SomeInterface @someDirective + + type AnotherType { field: String } + `); + + t.keyword('interface', { kind: 'InterfaceDef' }); + t.name('SomeInterface'); + expectDirective({ t }, { name: 'someDirective' }); + + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('AnotherType'); + t.punctuation('{', { kind: 'FieldDefs' }); + t.property('field', { kind: 'FieldDef' }); + t.punctuation(':'); + t.name('String', { kind: 'NamedType' }); + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); + it('with a directive', () => { const { t } = getUtils('interface SomeInterface @someDirective'); @@ -1133,6 +1155,52 @@ describe('onlineParser', () => { ); }); + describe('parses extend interface def', () => { + it('correctly', () => { + const { t } = getUtils(` + extend interface SomeInterface { + someField: SomeType + } + `); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('interface', { kind: 'InterfaceDef' }); + t.name('SomeInterface'); + t.punctuation('{', { kind: 'FieldDefs' }); + + t.property('someField', { kind: 'FieldDef' }); + t.punctuation(':'); + t.name('SomeType', { kind: 'NamedType' }); + + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); + + it('with no fields body, only a directive', () => { + const { t } = getUtils(` + extend interface SomeInterface @someDirective + + type AnotherType { field: String } + `); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('interface', { kind: 'InterfaceDef' }); + t.name('SomeInterface'); + expectDirective({ t }, { name: 'someDirective' }); + + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('AnotherType'); + t.punctuation('{', { kind: 'FieldDefs' }); + t.property('field', { kind: 'FieldDef' }); + t.punctuation(':'); + t.name('String', { kind: 'NamedType' }); + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); + }); + describe('parses field defs', () => { it('correctly', () => { const { t } = getUtils(` From e2fc5e7b7000d9b3df6b8e86d93b0c333fb54ced Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Wed, 6 May 2026 12:50:54 -0700 Subject: [PATCH 08/11] Allow optional members in `union` and `extend union` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the GraphQL spec, both `UnionTypeDefinition` and `UnionTypeExtension` permit omitting the `= NamedType | …` member list: - https://spec.graphql.org/draft/#sec-Unions - https://spec.graphql.org/draft/#sec-Union-Extensions Extract the `=` and member list into a new `UnionMembers` rule and mark it as optional in `UnionDef`. `[Kind.UNION_TYPE_EXTENSION]` continues to reuse `UnionDef` and inherits the relaxed grammar. --- .../src/parser/Rules.ts | 4 +- .../src/parser/__tests__/OnlineParser.test.ts | 71 +++++++++++++++++-- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/graphql-language-service/src/parser/Rules.ts b/packages/graphql-language-service/src/parser/Rules.ts index c80a1fe5db5..d52fb70fed7 100644 --- a/packages/graphql-language-service/src/parser/Rules.ts +++ b/packages/graphql-language-service/src/parser/Rules.ts @@ -271,9 +271,9 @@ export const ParseRules: { [name: string]: ParseRule } = { word('union'), name('atom'), list('Directive'), - p('='), - list('UnionMember', p('|')), + opt('UnionMembers'), ], + UnionMembers: [p('='), list('UnionMember', p('|'))], UnionMember: ['NamedType'], EnumDef: [ diff --git a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts index 4fde4ab7b38..01d2bae81fa 100644 --- a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts +++ b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts @@ -1508,9 +1508,9 @@ describe('onlineParser', () => { t.keyword('union', { kind: 'UnionDef' }); t.name('SomeUnionType'); - t.punctuation('='); + t.punctuation('=', { kind: 'UnionMembers' }); t.name('SomeType', { kind: 'NamedType' }); - t.punctuation('|', { kind: 'UnionDef' }); + t.punctuation('|', { kind: 'UnionMembers' }); t.name('AnotherType', { kind: 'NamedType' }); t.eol(); @@ -1524,13 +1524,76 @@ describe('onlineParser', () => { t.keyword('union', { kind: 'UnionDef' }); t.name('SomeUnionType'); expectDirective({ t }, { name: 'someDirective' }); - t.punctuation('=', { kind: 'UnionDef' }); + t.punctuation('=', { kind: 'UnionMembers' }); t.name('SomeType', { kind: 'NamedType' }); - t.punctuation('|', { kind: 'UnionDef' }); + t.punctuation('|', { kind: 'UnionMembers' }); t.name('AnotherType', { kind: 'NamedType' }); t.eol(); }); + + it('with no members, only a directive', () => { + const { t } = getUtils(` + union SomeUnionType @someDirective + + type AnotherType { field: String } + `); + + t.keyword('union', { kind: 'UnionDef' }); + t.name('SomeUnionType'); + expectDirective({ t }, { name: 'someDirective' }); + + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('AnotherType'); + t.punctuation('{', { kind: 'FieldDefs' }); + t.property('field', { kind: 'FieldDef' }); + t.punctuation(':'); + t.name('String', { kind: 'NamedType' }); + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); + }); + + describe('parses extend union def', () => { + it('correctly', () => { + const { t } = getUtils( + 'extend union SomeUnionType = SomeType | AnotherType', + ); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('union', { kind: 'UnionDef' }); + t.name('SomeUnionType'); + t.punctuation('=', { kind: 'UnionMembers' }); + t.name('SomeType', { kind: 'NamedType' }); + t.punctuation('|', { kind: 'UnionMembers' }); + t.name('AnotherType', { kind: 'NamedType' }); + + t.eol(); + }); + + it('with no members, only a directive', () => { + const { t } = getUtils(` + extend union SomeUnionType @someDirective + + type AnotherType { field: String } + `); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('union', { kind: 'UnionDef' }); + t.name('SomeUnionType'); + expectDirective({ t }, { name: 'someDirective' }); + + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('AnotherType'); + t.punctuation('{', { kind: 'FieldDefs' }); + t.property('field', { kind: 'FieldDef' }); + t.punctuation(':'); + t.name('String', { kind: 'NamedType' }); + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); }); describe('parses directive type def', () => { From b8c7520108f2a167b09592c587fb2186b92c0b58 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Wed, 6 May 2026 12:51:35 -0700 Subject: [PATCH 09/11] Allow optional values body in `enum` and `extend enum` Per the GraphQL spec, both `EnumTypeDefinition` and `EnumTypeExtension` permit omitting the `{ EnumValueDefinition+ }` body: - https://spec.graphql.org/draft/#sec-Enums - https://spec.graphql.org/draft/#sec-Enum-Extensions Extract the body into a new `EnumValueDefs` rule and mark it as optional in `EnumDef`. `[Kind.ENUM_TYPE_EXTENSION]` continues to reuse `EnumDef` and inherits the relaxed grammar. --- .../src/parser/Rules.ts | 5 +- .../src/parser/__tests__/OnlineParser.test.ts | 70 ++++++++++++++++++- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/graphql-language-service/src/parser/Rules.ts b/packages/graphql-language-service/src/parser/Rules.ts index d52fb70fed7..f08814d2ac6 100644 --- a/packages/graphql-language-service/src/parser/Rules.ts +++ b/packages/graphql-language-service/src/parser/Rules.ts @@ -280,10 +280,9 @@ export const ParseRules: { [name: string]: ParseRule } = { word('enum'), name('atom'), list('Directive'), - p('{'), - list('EnumValueDef'), - p('}'), + opt('EnumValueDefs'), ], + EnumValueDefs: [p('{'), list('EnumValueDef'), p('}')], EnumValueDef: [name('string-2'), list('Directive')], InputDef: [ diff --git a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts index 01d2bae81fa..87313c3bd27 100644 --- a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts +++ b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts @@ -1449,7 +1449,7 @@ describe('onlineParser', () => { t.keyword('enum', { kind: 'EnumDef' }); t.name('SomeEnum'); - t.punctuation('{'); + t.punctuation('{', { kind: 'EnumValueDefs' }); t.value('Enum', 'SOME_ENUM_VALUE', { kind: 'EnumValueDef' }); t.value('Enum', 'ANOTHER_ENUM_VALUE', { kind: 'EnumValueDef' }); @@ -1470,7 +1470,7 @@ describe('onlineParser', () => { t.keyword('enum', { kind: 'EnumDef' }); t.name('SomeEnum'); expectDirective({ t }, { name: 'someDirective' }); - t.punctuation('{', { kind: 'EnumDef' }); + t.punctuation('{', { kind: 'EnumValueDefs' }); t.value('Enum', 'SOME_ENUM_VALUE', { kind: 'EnumValueDef' }); t.value('Enum', 'ANOTHER_ENUM_VALUE', { kind: 'EnumValueDef' }); @@ -1479,6 +1479,72 @@ describe('onlineParser', () => { t.eol(); }); + + it('with no values body, only a directive', () => { + const { t } = getUtils(` + enum SomeEnum @someDirective + + type AnotherType { field: String } + `); + + t.keyword('enum', { kind: 'EnumDef' }); + t.name('SomeEnum'); + expectDirective({ t }, { name: 'someDirective' }); + + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('AnotherType'); + t.punctuation('{', { kind: 'FieldDefs' }); + t.property('field', { kind: 'FieldDef' }); + t.punctuation(':'); + t.name('String', { kind: 'NamedType' }); + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); + }); + + describe('parses extend enum def', () => { + it('correctly', () => { + const { t } = getUtils(` + extend enum SomeEnum { + SOME_ENUM_VALUE + } + `); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('enum', { kind: 'EnumDef' }); + t.name('SomeEnum'); + t.punctuation('{', { kind: 'EnumValueDefs' }); + + t.value('Enum', 'SOME_ENUM_VALUE', { kind: 'EnumValueDef' }); + + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); + + it('with no values body, only a directive', () => { + const { t } = getUtils(` + extend enum SomeEnum @someDirective + + type AnotherType { field: String } + `); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('enum', { kind: 'EnumDef' }); + t.name('SomeEnum'); + expectDirective({ t }, { name: 'someDirective' }); + + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('AnotherType'); + t.punctuation('{', { kind: 'FieldDefs' }); + t.property('field', { kind: 'FieldDef' }); + t.punctuation(':'); + t.name('String', { kind: 'NamedType' }); + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); }); describe('parses scalar type def', () => { From 66461c3fa74d944a48db86e1f73e2981c07319cc Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Wed, 6 May 2026 12:52:10 -0700 Subject: [PATCH 10/11] Allow optional fields body in `input` and `extend input` Per the GraphQL spec, both `InputObjectTypeDefinition` and `InputObjectTypeExtension` permit omitting the `{ InputValueDefinition+ }` body: - https://spec.graphql.org/draft/#sec-Input-Objects - https://spec.graphql.org/draft/#sec-Input-Object-Extensions Extract the body into a new `InputValueDefs` rule and mark it as optional in `InputDef`. `[Kind.INPUT_OBJECT_TYPE_EXTENSION]` continues to reuse `InputDef` and inherits the relaxed grammar. --- .../src/parser/Rules.ts | 5 +- .../src/parser/__tests__/OnlineParser.test.ts | 72 ++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/graphql-language-service/src/parser/Rules.ts b/packages/graphql-language-service/src/parser/Rules.ts index f08814d2ac6..210c8c67b9a 100644 --- a/packages/graphql-language-service/src/parser/Rules.ts +++ b/packages/graphql-language-service/src/parser/Rules.ts @@ -289,10 +289,9 @@ export const ParseRules: { [name: string]: ParseRule } = { word('input'), name('atom'), list('Directive'), - p('{'), - list('InputValueDef'), - p('}'), + opt('InputValueDefs'), ], + InputValueDefs: [p('{'), list('InputValueDef'), p('}')], ExtendDef: [word('extend'), 'ExtensionDefinition'], ExtensionDefinition(token: Token): RuleKind | void { switch (token.value) { diff --git a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts index 87313c3bd27..f594d80ee6d 100644 --- a/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts +++ b/packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts @@ -1404,7 +1404,7 @@ describe('onlineParser', () => { t.keyword('input', { kind: 'InputDef' }); t.name('SomeInputType'); - t.punctuation('{'); + t.punctuation('{', { kind: 'InputValueDefs' }); t.attribute('someField', { kind: 'InputValueDef' }); t.punctuation(':'); @@ -1424,7 +1424,7 @@ describe('onlineParser', () => { t.keyword('input', { kind: 'InputDef' }); t.name('SomeInputType'); - t.punctuation('{'); + t.punctuation('{', { kind: 'InputValueDefs' }); t.attribute('someField', { kind: 'InputValueDef' }); t.punctuation(':'); @@ -1436,6 +1436,74 @@ describe('onlineParser', () => { t.eol(); }); + + it('with no fields body, only a directive', () => { + const { t } = getUtils(` + input SomeInputType @someDirective + + type AnotherType { field: String } + `); + + t.keyword('input', { kind: 'InputDef' }); + t.name('SomeInputType'); + expectDirective({ t }, { name: 'someDirective' }); + + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('AnotherType'); + t.punctuation('{', { kind: 'FieldDefs' }); + t.property('field', { kind: 'FieldDef' }); + t.punctuation(':'); + t.name('String', { kind: 'NamedType' }); + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); + }); + + describe('parses extend input def', () => { + it('correctly', () => { + const { t } = getUtils(` + extend input SomeInputType { + someField: AnotherType + } + `); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('input', { kind: 'InputDef' }); + t.name('SomeInputType'); + t.punctuation('{', { kind: 'InputValueDefs' }); + + t.attribute('someField', { kind: 'InputValueDef' }); + t.punctuation(':'); + t.name('AnotherType', { kind: 'NamedType' }); + + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); + + it('with no fields body, only a directive', () => { + const { t } = getUtils(` + extend input SomeInputType @someDirective + + type AnotherType { field: String } + `); + + t.keyword('extend', { kind: 'ExtendDef' }); + t.keyword('input', { kind: 'InputDef' }); + t.name('SomeInputType'); + expectDirective({ t }, { name: 'someDirective' }); + + t.keyword('type', { kind: 'ObjectTypeDef' }); + t.name('AnotherType'); + t.punctuation('{', { kind: 'FieldDefs' }); + t.property('field', { kind: 'FieldDef' }); + t.punctuation(':'); + t.name('String', { kind: 'NamedType' }); + t.punctuation('}', { kind: 'Document' }); + + t.eol(); + }); }); describe('parses enum type def', () => { From 0f7148c589916c52b3c9a651a63f284be128ba87 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Wed, 6 May 2026 12:55:15 -0700 Subject: [PATCH 11/11] Update changeset to cover all type/extension body relaxations --- .changeset/afraid-oranges-clean.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.changeset/afraid-oranges-clean.md b/.changeset/afraid-oranges-clean.md index 417922cf249..b093c2f1728 100644 --- a/.changeset/afraid-oranges-clean.md +++ b/.changeset/afraid-oranges-clean.md @@ -2,8 +2,16 @@ 'graphql-language-service': patch --- -fix: Correctly parse schema extensions with no root operations +Align schema-language parser bodies with the GraphQL spec. -Previously, the parser gave schema extensions the same treatment as schema definitions. The requirements are slightly different, however, since a schema extension does not require a list of root operations according to the spec: https://spec.graphql.org/draft/#sec-Schema-Extension. +The online parser previously required a body in several places where +the spec marks it as optional, causing valid schema documents to fail +to tokenize cleanly when a definition or extension omitted its body. +The following are now parsed correctly: -The rule for parsing a schema extension is now distinct from that for a schema definition, allowing the root operations list to be omitted. +- `extend schema` with no root operation type definitions +- `type` / `extend type` with no fields body +- `interface` / `extend interface` with no fields body +- `union` / `extend union` with no member list +- `enum` / `extend enum` with no values body +- `input` / `extend input` with no fields body