Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nine-pears-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@0no-co/graphqlsp': minor
---

Support [fragment arguments](https://github.com/graphql/graphql-spec/pull/1081) for projects on GraphQL 17. Documents are parsed with `experimentalFragmentArguments`, and diagnostics validate the parsed document instead of letting `graphql-language-service` re-parse it without that option, so `fragment Fields($size: Int!) on Product` and `...Fields(size: $size)` no longer report a syntax error. Older GraphQL versions ignore the option and are unaffected.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@rollup/plugin-typescript": "^12.3.0",
"@types/node": "^22.20.0",
"dotenv": "^17.4.2",
"graphql17": "npm:graphql@^17.0.0",
"husky": "^9.1.7",
"lint-staged": "^17.0.8",
"prettier": "^3.9.4",
Expand Down
4 changes: 3 additions & 1 deletion packages/graphqlsp/src/ast/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ts } from '../ts';
import { FragmentDefinitionNode, parse } from 'graphql';
import { FragmentDefinitionNode } from 'graphql';

import { parse } from '../graphql/parse';
import * as checks from './checks';
import { resolveTadaFragmentArray } from './resolve';
import {
Expand Down
4 changes: 3 additions & 1 deletion packages/graphqlsp/src/autoComplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import {
CharacterStream,
ContextToken,
} from 'graphql-language-service';
import { FragmentDefinitionNode, GraphQLSchema, Kind, parse } from 'graphql';
import { FragmentDefinitionNode, GraphQLSchema, Kind } from 'graphql';

import { parse } from './graphql/parse';
import { print } from '@0no-co/graphql.web';

import * as checks from './ast/checks';
Expand Down
4 changes: 3 additions & 1 deletion packages/graphqlsp/src/checkImports.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ts } from './ts';
import { FragmentDefinitionNode, Kind, parse } from 'graphql';
import { FragmentDefinitionNode, Kind } from 'graphql';

import { parse } from './graphql/parse';

import { findAllCallExpressions, findAllImports } from './ast';
import { resolveTemplate } from './ast/resolve';
Expand Down
3 changes: 2 additions & 1 deletion packages/graphqlsp/src/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import {
TypeInfo,
isInterfaceType,
isObjectType,
parse,
visit,
visitWithTypeInfo,
} from 'graphql';

import { parse } from './graphql/parse';

import { ts } from './ts';
import {
bubbleUpCallExpression,
Expand Down
42 changes: 37 additions & 5 deletions packages/graphqlsp/src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { ts } from './ts';
import { Diagnostic, getDiagnostics } from 'graphql-language-service';
import {
Diagnostic,
getDiagnostics,
validateQuery,
} from 'graphql-language-service';
import {
FragmentDefinitionNode,
GraphQLSchema,
Kind,
OperationDefinitionNode,
parse,
print as printNode,
visit,
} from 'graphql';
import { LRUCache } from 'lru-cache';
Expand Down Expand Up @@ -33,6 +38,35 @@ import {
getDocumentReferenceFromTypeQuery,
} from './persisted';
import { SchemaRef } from './graphql/getSchema';
import { parse } from './graphql/parse';

/** Runs `graphql-language-service`'s diagnostics over a document.
*
* `getDiagnostics` parses the document itself, without the parse options
* GraphQLSP passes, so fragment arguments would be reported as syntax errors.
* Parsing here and handing the AST to `validateQuery` keeps that path intact,
* while still falling back to `getDiagnostics` for the ranged syntax error it
* reports when a document genuinely doesn't parse.
*/
function getDocumentDiagnostics(
text: string,
schema: GraphQLSchema,
fragments: FragmentDefinitionNode[]
) {
const externalFragments = fragments.reduce(
(acc, node) => acc + printNode(node) + '\n\n',
''
);
const enhancedText = externalFragments
? `${text}\n\n${externalFragments}`
: text;

try {
return validateQuery(parse(enhancedText), schema);
} catch (e) {
return getDiagnostics(text, schema, undefined, undefined, fragments);
}
}

const BASE_CLIENT_DIRECTIVES = new Set([
'populate',
Expand Down Expand Up @@ -754,11 +788,9 @@ const runDiagnostics = (
...(info.config.clientDirectives || []),
]);

const graphQLDiagnostics = getDiagnostics(
const graphQLDiagnostics = getDocumentDiagnostics(
text,
schemaToUse,
undefined,
undefined,
docFragments
)
.filter(diag => {
Expand Down
4 changes: 3 additions & 1 deletion packages/graphqlsp/src/fieldUsage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ts } from './ts';
import { parse, visit } from 'graphql';
import { visit } from 'graphql';

import { parse } from './graphql/parse';

import { getValueOfIdentifier } from './ast/declaration';

Expand Down
22 changes: 22 additions & 0 deletions packages/graphqlsp/src/graphql/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { parse as parseDocument } from 'graphql';
import type { DocumentNode, ParseOptions, Source } from 'graphql';

/**
* Fragment arguments — `fragment Fields($size: Int!) on Product` and
* `...Fields(size: $size)` — are gated behind an experimental parse option in
* `graphql@17`. Older versions ignore unknown parse options, so this can always
* be passed; on those versions the syntax simply keeps reporting a syntax error.
*
* @see https://github.com/graphql/graphql-spec/pull/1081
*/
export const PARSE_OPTIONS = {
experimentalFragmentArguments: true,
} as ParseOptions;

/** `graphql`'s `parse`, with the parse options GraphQLSP relies on. */
export function parse(
source: string | Source,
options?: ParseOptions
): DocumentNode {
return parseDocument(source, { ...PARSE_OPTIONS, ...options });
}
16 changes: 12 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

97 changes: 97 additions & 0 deletions test/unit/fragmentArguments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest';
import { buildSchema } from '../../packages/graphqlsp/node_modules/graphql/index.js';
import { parse as parse17 } from 'graphql17';

import { createTestEnvironment, TADA_GRAPHQL_MODULE } from './language-service';
import {
parse,
PARSE_OPTIONS,
} from '../../packages/graphqlsp/src/graphql/parse';
import {
getGraphQLDiagnostics,
SEMANTIC_DIAGNOSTIC_CODE,
} from '../../packages/graphqlsp/src/diagnostics';
import type { SchemaRef } from '../../packages/graphqlsp/src/graphql/getSchema';

const FRAGMENT_ARGUMENTS_DOCUMENT = `
fragment Fields($limit: Int! = 2) on Pokemon {
id
attacks(limit: $limit)
}

query One($limit: Int!) {
pokemon { ...Fields(limit: $limit) }
}
`;

const makeSchemaRef = (): SchemaRef => {
const schema = buildSchema(`
type Query { pokemon: Pokemon }
type Pokemon { id: ID, name: String, attacks(limit: Int): [String] }
`);
return {
current: { schema },
multi: { pokemons: { schema } },
version: 1,
errors: { config: null, load: new Map(), write: new Map() },
outputLocations: new Map(),
sourceLocations: new Map(),
turboLocations: new Map(),
checkStale() {},
} as unknown as SchemaRef;
};

describe('fragment arguments', () => {
it('reads fragment arguments when the project is on GraphQL 17', () => {
// GraphQLSP parses with the user's `graphql`; this is the version that
// understands the syntax, and `PARSE_OPTIONS` is what unlocks it.
const document = parse17(FRAGMENT_ARGUMENTS_DOCUMENT, PARSE_OPTIONS);

const fragment = document.definitions[0];
const operation = document.definitions[1];
if (
fragment?.kind !== 'FragmentDefinition' ||
operation?.kind !== 'OperationDefinition'
) {
throw new Error('Expected a fragment definition and an operation.');
}

expect(
fragment.variableDefinitions?.map(x => x.variable.name.value)
).toEqual(['limit']);

const spread = operation.selectionSet.selections[0];
expect(
spread?.kind === 'Field' &&
spread.selectionSet?.selections[0]?.kind === 'FragmentSpread' &&
spread.selectionSet.selections[0].arguments?.map(x => x.name.value)
).toEqual(['limit']);
});

it('leaves parsing unchanged on GraphQL versions without the option', () => {
// Older versions ignore unknown parse options, so passing it is inert
// rather than a hard failure.
expect(() => parse('query One { pokemon { id } }')).not.toThrow();
expect(() => parse(FRAGMENT_ARGUMENTS_DOCUMENT)).toThrow(/Syntax Error/);
});

it('still reports the syntax error on GraphQL versions without the option', () => {
const { info, getSourceFile } = createTestEnvironment({
'/test-project/graphql.ts': TADA_GRAPHQL_MODULE,
'/test-project/index.ts': `
import { graphql } from './graphql';
const Query = graphql(\`${FRAGMENT_ARGUMENTS_DOCUMENT}\`);
`,
});

const source = getSourceFile('/test-project/index.ts');
const diagnostics = getGraphQLDiagnostics(
source.fileName,
makeSchemaRef(),
info
);

expect(diagnostics?.map(x => x.code)).toEqual([SEMANTIC_DIAGNOSTIC_CODE]);
expect(`${diagnostics?.[0]?.messageText}`).toContain('Syntax Error');
});
});