From b9aa9aa6fb6704d826201bfa2a48ec9335318df3 Mon Sep 17 00:00:00 2001 From: DukeDeSouth Date: Tue, 16 Jun 2026 19:24:26 -0400 Subject: [PATCH] fix(graphql-language-service): wrap list input values in brackets on autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completing an enum or boolean value for a list-typed argument or input field (e.g. `[Episode]`) inserted a bare `JEDI`, which isn't valid there. The online parser already unwraps one list level once the caret is inside a `[ ]`, so the remaining list depth tells us how many brackets to add — this also covers nested lists like `[[Episode]]`. Values typed inside an existing list literal, single (non-list) values and variables are left untouched. Closes #587 --- .changeset/wrap-list-input-values.md | 7 ++ .../getAutocompleteSuggestions.test.ts | 93 +++++++++++++++++++ .../interface/getAutocompleteSuggestions.ts | 24 +++++ 3 files changed, 124 insertions(+) create mode 100644 .changeset/wrap-list-input-values.md diff --git a/.changeset/wrap-list-input-values.md b/.changeset/wrap-list-input-values.md new file mode 100644 index 0000000000..95c6cb80ee --- /dev/null +++ b/.changeset/wrap-list-input-values.md @@ -0,0 +1,7 @@ +--- +'graphql-language-service': patch +--- + +Wrap autocompleted list input values in square brackets + +When you complete an enum or boolean value for a list-typed argument or input field (e.g. `[Episode]`), the suggestion now inserts `[JEDI]` instead of a bare `JEDI`, which produced an invalid query. Values completed inside an existing list literal, and non-list values, are left as they were. diff --git a/packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions.test.ts b/packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions.test.ts index 59f4b7b979..6e7bc73304 100644 --- a/packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions.test.ts +++ b/packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions.test.ts @@ -1023,3 +1023,96 @@ describe('getAutocompleteSuggestions', () => { ])); }); }); + +describe('getAutocompleteSuggestions - list input values (#587)', () => { + const listSchema = buildSchema(` + enum Episode { NEWHOPE EMPIRE JEDI } + input FilterInput { + episodes: [Episode] + one: Episode + } + type Query { + listEnum(values: [Episode]): String + listEnumNonNull(values: [Episode!]!): String + listBool(flags: [Boolean]): String + singleEnum(value: Episode): String + nestedList(values: [[Episode]]): String + nested(input: FilterInput): String + } + `); + + function valueSuggestions(query: string, label: string) { + const caret = query.indexOf('|'); + const text = query.replace('|', ''); + const before = text.slice(0, caret); + const line = before.split('\n').length - 1; + const character = before.length - (before.lastIndexOf('\n') + 1); + return getAutocompleteSuggestions( + listSchema, + text, + new Position(line, character), + ) + .filter(s => s.label === label) + .map(s => ({ label: s.label, insertText: s.insertText })); + } + + it('wraps an enum value in brackets at a list argument value position', () => { + expect(valueSuggestions('{ listEnum(values: |) }', 'JEDI')).toEqual([ + { label: 'JEDI', insertText: '[JEDI]' }, + ]); + }); + + it('wraps an enum value for a non-null list of non-null items', () => { + expect(valueSuggestions('{ listEnumNonNull(values: |) }', 'JEDI')).toEqual([ + { label: 'JEDI', insertText: '[JEDI]' }, + ]); + }); + + it('wraps a boolean value in brackets at a list argument value position', () => { + expect(valueSuggestions('{ listBool(flags: |) }', 'true')).toEqual([ + { label: 'true', insertText: '[true]' }, + ]); + }); + + it('wraps an enum value at a list input-object field value position', () => { + expect( + valueSuggestions('{ nested(input: { episodes: | }) }', 'JEDI'), + ).toEqual([{ label: 'JEDI', insertText: '[JEDI]' }]); + }); + + it('does not wrap when the cursor is already inside the list literal', () => { + expect(valueSuggestions('{ listEnum(values: [|]) }', 'JEDI')).toEqual([ + { label: 'JEDI', insertText: undefined }, + ]); + }); + + it('does not wrap a non-list (single) enum argument value', () => { + expect(valueSuggestions('{ singleEnum(value: |) }', 'JEDI')).toEqual([ + { label: 'JEDI', insertText: undefined }, + ]); + }); + + it('does not wrap a non-list (single) input-object enum field value', () => { + expect(valueSuggestions('{ nested(input: { one: | }) }', 'JEDI')).toEqual([ + { label: 'JEDI', insertText: undefined }, + ]); + }); + + it('wraps a nested list value with the matching number of brackets', () => { + expect(valueSuggestions('{ nestedList(values: |) }', 'JEDI')).toEqual([ + { label: 'JEDI', insertText: '[[JEDI]]' }, + ]); + }); + + it('wraps once when inside the outer bracket of a nested list', () => { + expect(valueSuggestions('{ nestedList(values: [|]) }', 'JEDI')).toEqual([ + { label: 'JEDI', insertText: '[JEDI]' }, + ]); + }); + + it('does not wrap when inside both brackets of a nested list', () => { + expect(valueSuggestions('{ nestedList(values: [[|]]) }', 'JEDI')).toEqual([ + { label: 'JEDI', insertText: undefined }, + ]); + }); +}); diff --git a/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts b/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts index 11f12b1ebf..5ee138e5f6 100644 --- a/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts +++ b/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts @@ -39,6 +39,8 @@ import { assertAbstractType, doTypesOverlap, getNamedType, + getNullableType, + isListType, isAbstractType, isCompositeType, isInputType, @@ -575,6 +577,25 @@ function getSuggestionsForInputValues( ): Array { const namedInputType = getNamedType(typeInfo.inputType!); + // When the value position expects a list (e.g. `[Episode]`), a bare literal + // like `JEDI` is invalid — it must be wrapped as `[JEDI]`. The online parser + // unwraps one list level each time the cursor moves inside a list literal + // (`[ | ]`), so the number of list wrappers still present here is exactly how + // many brackets we need to add (handles nested lists like `[[Episode]]` too). + // Variable completions ($var) reference the whole list and must not be wrapped. + let listDepth = 0; + let unwrapped: GraphQLType | null | undefined = getNullableType( + typeInfo.inputType, + ); + while (unwrapped && isListType(unwrapped)) { + listDepth++; + unwrapped = getNullableType(unwrapped.ofType); + } + const wrap = (literal: string): string | undefined => + listDepth > 0 + ? '['.repeat(listDepth) + literal + ']'.repeat(listDepth) + : undefined; + const queryVariables: CompletionItem[] = getVariableCompletions( queryText, schema, @@ -588,6 +609,7 @@ function getSuggestionsForInputValues( values .map((value: GraphQLEnumValue) => ({ label: value.name, + insertText: wrap(value.name), detail: String(namedInputType), documentation: value.description ?? undefined, deprecated: Boolean(value.deprecationReason), @@ -605,6 +627,7 @@ function getSuggestionsForInputValues( queryVariables.concat([ { label: 'true', + insertText: wrap('true'), detail: String(GraphQLBoolean), documentation: 'Not false.', kind: CompletionItemKind.Variable, @@ -612,6 +635,7 @@ function getSuggestionsForInputValues( }, { label: 'false', + insertText: wrap('false'), detail: String(GraphQLBoolean), documentation: 'Not true.', kind: CompletionItemKind.Variable,