diff --git a/packages/example-external-generator/package.json b/packages/example-external-generator/package.json index 9644e45f..8bdf8531 100644 --- a/packages/example-external-generator/package.json +++ b/packages/example-external-generator/package.json @@ -12,12 +12,14 @@ "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "@urql/core": "^3.0.0", - "graphql": "^16.8.1" + "graphql": "^16.8.1", + "urql": "^4.0.6" }, "devDependencies": { "@0no-co/graphqlsp": "file:../graphqlsp", "@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/client-preset": "^4.1.0", + "@types/react": "^18.2.45", "ts-node": "^10.9.1", "typescript": "^5.3.3" } diff --git a/packages/example-external-generator/src/Pokemon.tsx b/packages/example-external-generator/src/Pokemon.tsx index 57484e61..fe691254 100644 --- a/packages/example-external-generator/src/Pokemon.tsx +++ b/packages/example-external-generator/src/Pokemon.tsx @@ -14,12 +14,6 @@ export const PokemonFields = graphql(` } `) -export const WeakFields = graphql(` - fragment weaknessFields on Pokemon { - weaknesses - } -`) - export const Pokemon = (data: any) => { const pokemon = useFragment(PokemonFields, data); return `hi ${pokemon.name}`; diff --git a/packages/example-external-generator/src/gql/gql.ts b/packages/example-external-generator/src/gql/gql.ts index 0aac7628..edbe1b17 100644 --- a/packages/example-external-generator/src/gql/gql.ts +++ b/packages/example-external-generator/src/gql/gql.ts @@ -15,14 +15,8 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/ const documents = { '\n fragment pokemonFields on Pokemon {\n id\n name\n attacks {\n fast {\n damage\n name\n }\n }\n }\n': types.PokemonFieldsFragmentDoc, - '\n fragment weaknessFields on Pokemon {\n weaknesses\n }\n': - types.WeaknessFieldsFragmentDoc, - '\n query Pok($limit: Int!) {\n pokemons(limit: $limit) {\n id\n name\n fleeRate\n classification\n ...pokemonFields\n ...weaknessFields\n __typename\n }\n }\n': - types.PokDocument, - '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n __typename\n }\n }\n': + '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n': types.PoDocument, - '\n query PokemonsAreAwesome {\n pokemons {\n id\n }\n }\n': - types.PokemonsAreAwesomeDocument, }; /** @@ -49,26 +43,8 @@ export function graphql( * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql( - source: '\n fragment weaknessFields on Pokemon {\n weaknesses\n }\n' -): (typeof documents)['\n fragment weaknessFields on Pokemon {\n weaknesses\n }\n']; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql( - source: '\n query Pok($limit: Int!) {\n pokemons(limit: $limit) {\n id\n name\n fleeRate\n classification\n ...pokemonFields\n ...weaknessFields\n __typename\n }\n }\n' -): (typeof documents)['\n query Pok($limit: Int!) {\n pokemons(limit: $limit) {\n id\n name\n fleeRate\n classification\n ...pokemonFields\n ...weaknessFields\n __typename\n }\n }\n']; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql( - source: '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n __typename\n }\n }\n' -): (typeof documents)['\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n __typename\n }\n }\n']; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql( - source: '\n query PokemonsAreAwesome {\n pokemons {\n id\n }\n }\n' -): (typeof documents)['\n query PokemonsAreAwesome {\n pokemons {\n id\n }\n }\n']; + source: '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n' +): (typeof documents)['\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n']; export function graphql(source: string) { return (documents as any)[source] ?? {}; diff --git a/packages/example-external-generator/src/gql/graphql.ts b/packages/example-external-generator/src/gql/graphql.ts index 693f35b3..6d5427bf 100644 --- a/packages/example-external-generator/src/gql/graphql.ts +++ b/packages/example-external-generator/src/gql/graphql.ts @@ -131,52 +131,35 @@ export type PokemonFieldsFragment = { } | null; } & { ' $fragmentName'?: 'PokemonFieldsFragment' }; -export type WeaknessFieldsFragment = { - __typename?: 'Pokemon'; - weaknesses?: Array | null; -} & { ' $fragmentName'?: 'WeaknessFieldsFragment' }; - -export type PokQueryVariables = Exact<{ - limit: Scalars['Int']['input']; +export type PoQueryVariables = Exact<{ + id: Scalars['ID']['input']; }>; -export type PokQuery = { +export type PoQuery = { __typename?: 'Query'; - pokemons?: Array< + pokemon?: | ({ __typename: 'Pokemon'; id: string; - name: string; fleeRate?: number | null; - classification?: string | null; + name: string; + attacks?: { + __typename?: 'AttacksConnection'; + special?: Array<{ + __typename?: 'Attack'; + name?: string | null; + damage?: number | null; + } | null> | null; + } | null; + weight?: { + __typename?: 'PokemonDimension'; + minimum?: string | null; + maximum?: string | null; + } | null; } & { - ' $fragmentRefs'?: { - PokemonFieldsFragment: PokemonFieldsFragment; - WeaknessFieldsFragment: WeaknessFieldsFragment; - }; + ' $fragmentRefs'?: { PokemonFieldsFragment: PokemonFieldsFragment }; }) - | null - > | null; -}; - -export type PoQueryVariables = Exact<{ - id: Scalars['ID']['input']; -}>; - -export type PoQuery = { - __typename?: 'Query'; - pokemon?: { - __typename: 'Pokemon'; - id: string; - fleeRate?: number | null; - } | null; -}; - -export type PokemonsAreAwesomeQueryVariables = Exact<{ [key: string]: never }>; - -export type PokemonsAreAwesomeQuery = { - __typename?: 'Query'; - pokemons?: Array<{ __typename?: 'Pokemon'; id: string } | null> | null; + | null; }; export const PokemonFieldsFragmentDoc = { @@ -222,42 +205,20 @@ export const PokemonFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; -export const WeaknessFieldsFragmentDoc = { - kind: 'Document', - definitions: [ - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'weaknessFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'Pokemon' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'weaknesses' } }, - ], - }, - }, - ], -} as unknown as DocumentNode; -export const PokDocument = { +export const PoDocument = { kind: 'Document', definitions: [ { kind: 'OperationDefinition', operation: 'query', - name: { kind: 'Name', value: 'Pok' }, + name: { kind: 'Name', value: 'Po' }, variableDefinitions: [ { kind: 'VariableDefinition', - variable: { - kind: 'Variable', - name: { kind: 'Name', value: 'limit' }, - }, + variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, type: { kind: 'NonNullType', - type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, }, }, ], @@ -266,14 +227,14 @@ export const PokDocument = { selections: [ { kind: 'Field', - name: { kind: 'Name', value: 'pokemons' }, + name: { kind: 'Name', value: 'pokemon' }, arguments: [ { kind: 'Argument', - name: { kind: 'Name', value: 'limit' }, + name: { kind: 'Name', value: 'id' }, value: { kind: 'Variable', - name: { kind: 'Name', value: 'limit' }, + name: { kind: 'Name', value: 'id' }, }, }, ], @@ -281,20 +242,55 @@ export const PokDocument = { kind: 'SelectionSet', selections: [ { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } }, - { - kind: 'Field', - name: { kind: 'Name', value: 'classification' }, - }, { kind: 'FragmentSpread', name: { kind: 'Name', value: 'pokemonFields' }, }, { - kind: 'FragmentSpread', - name: { kind: 'Name', value: 'weaknessFields' }, + kind: 'Field', + name: { kind: 'Name', value: 'attacks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'special' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'damage' }, + }, + ], + }, + }, + ], + }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'weight' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'minimum' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'maximum' }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, ], }, @@ -340,94 +336,5 @@ export const PokDocument = { ], }, }, - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'weaknessFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'Pokemon' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'weaknesses' } }, - ], - }, - }, - ], -} as unknown as DocumentNode; -export const PoDocument = { - kind: 'Document', - definitions: [ - { - kind: 'OperationDefinition', - operation: 'query', - name: { kind: 'Name', value: 'Po' }, - variableDefinitions: [ - { - kind: 'VariableDefinition', - variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, - type: { - kind: 'NonNullType', - type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, - }, - }, - ], - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'pokemon' }, - arguments: [ - { - kind: 'Argument', - name: { kind: 'Name', value: 'id' }, - value: { - kind: 'Variable', - name: { kind: 'Name', value: 'id' }, - }, - }, - ], - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } }, - { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, - ], - }, - }, - ], - }, - }, ], } as unknown as DocumentNode; -export const PokemonsAreAwesomeDocument = { - kind: 'Document', - definitions: [ - { - kind: 'OperationDefinition', - operation: 'query', - name: { kind: 'Name', value: 'PokemonsAreAwesome' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'pokemons' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - ], - }, - }, - ], - }, - }, - ], -} as unknown as DocumentNode< - PokemonsAreAwesomeQuery, - PokemonsAreAwesomeQueryVariables ->; diff --git a/packages/example-external-generator/src/index.tsx b/packages/example-external-generator/src/index.tsx index f6d3f674..6e9e1211 100644 --- a/packages/example-external-generator/src/index.tsx +++ b/packages/example-external-generator/src/index.tsx @@ -1,22 +1,10 @@ -import { createClient } from '@urql/core'; +import { createClient, useQuery } from 'urql'; import { graphql } from './gql'; - -const x = graphql(` - query Pok($limit: Int!) { - pokemons(limit: $limit) @populate { - id - name - fleeRate - classification - ...pokemonFields - ...weaknessFields - __typename - } - } -`) +import { Pokemon } from './Pokemon'; const client = createClient({ url: '', + exchanges: [] }); const PokemonQuery = graphql(` @@ -24,22 +12,44 @@ const PokemonQuery = graphql(` pokemon(id: $id) { id fleeRate + ...pokemonFields + attacks { + special { + name + damage + } + } + weight { + minimum + maximum + } + name __typename } } `); -client - .query(PokemonQuery, { id: '' }) - .toPromise() - .then(result => { - result.data?.pokemon; +const Pokemons = () => { + const [result] = useQuery({ + query: PokemonQuery, + variables: { id: '' } }); + + // Works + console.log(result.data?.pokemon?.attacks && result.data?.pokemon?.attacks.special && result.data?.pokemon?.attacks.special[0] && result.data?.pokemon?.attacks.special[0].name) + + // Works + const { fleeRate } = result.data?.pokemon; + console.log(fleeRate) + // Works + const po = result.data?.pokemon; + const { data: { pokemon: { weight: { minimum } } } } = result; + console.log(po?.name, minimum) + + // Does not work + const { pokemon: pok } = result.data; + console.log(pok.weight.maximum) + + return ; +} -const myQuery = graphql(` - query PokemonsAreAwesome { - pokemons { - id - } - } -`); diff --git a/packages/example-external-generator/tsconfig.json b/packages/example-external-generator/tsconfig.json index 6124d121..aa34b1b5 100644 --- a/packages/example-external-generator/tsconfig.json +++ b/packages/example-external-generator/tsconfig.json @@ -10,6 +10,7 @@ "templateIsCallExpression": true } ], + "jsx": "react-jsx", /* Language and Environment */ "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, /* Modules */ diff --git a/packages/example/src/index.generated.ts b/packages/example/src/index.generated.ts index 5b89fe48..ebe9bf0b 100644 --- a/packages/example/src/index.generated.ts +++ b/packages/example/src/index.generated.ts @@ -1,225 +1,18 @@ import * as Types from '../__generated__/baseGraphQLSP'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; -export type PokQueryVariables = Types.Exact<{ - limit: Types.Scalars['Int']['input']; +export type PoQueryVariables = Types.Exact<{ + id: Types.Scalars['ID']['input']; }>; -export type PokQuery = { +export type PoQuery = { __typename: 'Query'; - pokemons?: Array<{ + pokemon?: { __typename: 'Pokemon'; id: string; - name: string; fleeRate?: number | null; - classification?: string | null; - weaknesses?: Array | null; - attacks?: { - __typename: 'AttacksConnection'; - fast?: Array<{ - __typename: 'Attack'; - damage?: number | null; - name?: string | null; - } | null> | null; - } | null; - } | null> | null; -}; - -export type PokemonFieldsFragment = { - __typename: 'Pokemon'; - id: string; - name: string; - attacks?: { - __typename: 'AttacksConnection'; - fast?: Array<{ - __typename: 'Attack'; - damage?: number | null; - name?: string | null; - } | null> | null; } | null; }; -export type WeaknessFieldsFragment = { - __typename: 'Pokemon'; - weaknesses?: Array | null; -}; - -export type PoQueryVariables = Types.Exact<{ - id: Types.Scalars['ID']['input']; -}>; - -export const PokemonFieldsFragmentDoc = { - kind: 'Document', - definitions: [ - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'pokemonFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'Pokemon' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, - { - kind: 'Field', - name: { kind: 'Name', value: 'attacks' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'fast' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'damage' }, - }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - ], -} as unknown as DocumentNode; -export const WeaknessFieldsFragmentDoc = { - kind: 'Document', - definitions: [ - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'weaknessFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'Pokemon' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'weaknesses' } }, - ], - }, - }, - ], -} as unknown as DocumentNode; -export const PokDocument = { - kind: 'Document', - definitions: [ - { - kind: 'OperationDefinition', - operation: 'query', - name: { kind: 'Name', value: 'Pok' }, - variableDefinitions: [ - { - kind: 'VariableDefinition', - variable: { - kind: 'Variable', - name: { kind: 'Name', value: 'limit' }, - }, - type: { - kind: 'NonNullType', - type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, - }, - }, - ], - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'pokemons' }, - arguments: [ - { - kind: 'Argument', - name: { kind: 'Name', value: 'limit' }, - value: { - kind: 'Variable', - name: { kind: 'Name', value: 'limit' }, - }, - }, - ], - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, - { kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } }, - { - kind: 'FragmentSpread', - name: { kind: 'Name', value: 'pokemonFields' }, - }, - { - kind: 'FragmentSpread', - name: { kind: 'Name', value: 'weaknessFields' }, - }, - { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, - ], - }, - }, - ], - }, - }, - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'pokemonFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'Pokemon' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, - { - kind: 'Field', - name: { kind: 'Name', value: 'attacks' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'fast' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'damage' }, - }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'weaknessFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'Pokemon' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'weaknesses' } }, - ], - }, - }, - ], -} as unknown as DocumentNode; export const PoDocument = { kind: 'Document', definitions: [ @@ -266,4 +59,4 @@ export const PoDocument = { }, }, ], -} as unknown as DocumentNode; +} as unknown as DocumentNode; diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index 07a91d81..4316d24a 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -1,27 +1,6 @@ import { gql, createClient } from '@urql/core'; import { Pokemon, PokemonFields, WeakFields } from './Pokemon'; -const x = gql` - query Pok($limit: Int!) { - pokemons(limit: $limit) @populate { - id - name - fleeRate - classification - ...pokemonFields - ...weaknessFields - __typename - } - } - - ${PokemonFields} - ${WeakFields} -` as typeof import('./index.generated').PokDocument; - -const client = createClient({ - url: '', -}); - const PokemonQuery = gql` query Po($id: ID!) { pokemon(id: $id) { @@ -38,11 +17,3 @@ client .then(result => { result.data?.pokemon; }); - -const myQuery = gql` - query PokemonsAreAwesome { - pokemons { - id - } - } -`; diff --git a/packages/graphqlsp/src/diagnostics.ts b/packages/graphqlsp/src/diagnostics.ts index e06d28b9..2023a0c8 100644 --- a/packages/graphqlsp/src/diagnostics.ts +++ b/packages/graphqlsp/src/diagnostics.ts @@ -1,4 +1,4 @@ -import ts from 'typescript/lib/tsserverlibrary'; +import ts, { isForStatement } from 'typescript/lib/tsserverlibrary'; import { Diagnostic, getDiagnostics } from 'graphql-language-service'; import { FragmentDefinitionNode, @@ -7,6 +7,7 @@ import { OperationDefinitionNode, parse, print, + visit, } from 'graphql'; import { LRUCache } from 'lru-cache'; import fnv1a from '@sindresorhus/fnv1a'; @@ -15,11 +16,13 @@ import { findAllCallExpressions, findAllImports, findAllTaggedTemplateNodes, + findNode, getSource, isFileDirty, } from './ast'; import { resolveTemplate } from './ast/resolve'; import { generateTypedDocumentNodes } from './graphql/generateTypes'; +import { Logger } from '.'; const clientDirectives = new Set([ 'populate', @@ -40,6 +43,7 @@ export const SEMANTIC_DIAGNOSTIC_CODE = 52001; export const MISSING_OPERATION_NAME_CODE = 52002; export const MISSING_FRAGMENT_CODE = 52003; export const USING_DEPRECATED_FIELD_CODE = 52004; +export const UNUSED_FIELD_CODE = 52005; let isGeneratingTypes = false; @@ -93,7 +97,7 @@ export function getGraphQLDiagnostics( let tsDiagnostics: ts.Diagnostic[] = []; const cacheKey = fnv1a( isCallExpression - ? texts.join('-') + + ? source.getText() + fragments.map(x => print(x)).join('-') + schema.version : texts.join('-') + schema.version @@ -304,11 +308,426 @@ const runDiagnostics = ( messageText: diag.message.split('\n')[0], })); - const importDiagnostics = checkImportsForFragments(source, info); + const importDiagnostics = isCallExpression + ? // TODO: this should be opted out of the cache or we just add the whole file into the cache. + checkFieldUsageInFile( + source, + nodes as ts.NoSubstitutionTemplateLiteral[], + info + ) + : checkImportsForFragments(source, info); return [...tsDiagnostics, ...importDiagnostics]; }; +const getVariableDeclaration = (start: ts.NoSubstitutionTemplateLiteral) => { + let node: any = start; + let counter = 0; + while (!ts.isVariableDeclaration(node) && node.parent && counter < 5) { + node = node.parent; + counter++; + } + return node; +}; + +const traverseDestructuring = ( + node: ts.ObjectBindingPattern, + originalWip: Array, + allFields: Array, + source: ts.SourceFile, + info: ts.server.PluginCreateInfo +): Array => { + const logger: Logger = (msg: string) => + info.project.projectService.logger.info(`[GraphQLSP] ${msg}`); + + const results = []; + for (const binding of node.elements) { + logger(`traverseDestructuring binding ${binding.getText()}`); + if (ts.isObjectBindingPattern(binding.name)) { + const wip = [...originalWip]; + if ( + binding.propertyName && + allFields.includes(binding.propertyName.getText()) && + !originalWip.includes(binding.propertyName.getText()) + ) { + wip.push(binding.propertyName.getText()); + } + const traverseResult = traverseDestructuring( + binding.name, + wip, + allFields, + source, + info + ); + logger(`crawl binding ${JSON.stringify(traverseResult)}`); + results.push(...traverseResult); + } else if (ts.isIdentifier(binding.name)) { + const wip = [...originalWip]; + if ( + binding.propertyName && + allFields.includes(binding.propertyName.getText()) && + !originalWip.includes(binding.propertyName.getText()) + ) { + wip.push(binding.propertyName.getText()); + } else { + wip.push(binding.name.getText()); + } + logger(`crawl scope ${binding.getText()} ${JSON.stringify(wip)}`); + + const crawlResult = crawlScope( + binding.name, + wip, + allFields, + source, + info + ); + + if (!crawlResult.length) { + results.push(wip.join('.')); + } else { + results.push(...crawlResult); + } + logger(`crawl scope ${JSON.stringify(crawlResult)}`); + } + } + + logger( + `traverseDestructuring results ${JSON.stringify(results, undefined, 2)}` + ); + return results; +}; + +// TODO: this can be consolidated with the main crawler I presume... +const crawlScope = ( + node: ts.Identifier, + originalWip: Array, + allFields: Array, + source: ts.SourceFile, + info: ts.server.PluginCreateInfo +): Array => { + const logger: Logger = (msg: string) => + info.project.projectService.logger.info(`[GraphQLSP] ${msg}`); + let results: string[] = []; + + const typeChecker = info.languageService.getProgram()?.getTypeChecker(); + if (!typeChecker) return results; + + const symbol = typeChecker.getSymbolAtLocation(node); + if (!symbol) return results; + + const references = info.languageService.findReferences( + source.fileName, + node.pos + ); + logger( + `Crawl ${node.getText()} ${node.pos} ${JSON.stringify( + references, + undefined, + 2 + )}` + ); + if (!references) return results; + + logger(`Crawl ${JSON.stringify(originalWip, undefined, 2)}`); + // TODO: this is kinda weird as the first reference seems to be pointing at + // the generated stuff but with an erroneous filename + results = references[0].references.flatMap(ref => { + if (ref.fileName !== source.fileName) return []; + + if ( + node.getStart() <= ref.textSpan.start && + node.getEnd() >= ref.textSpan.start + ref.textSpan.length + ) + return []; + + let foundRef = findNode(source, ref.textSpan.start); + if (!foundRef) return []; + + const pathParts = [...originalWip]; + logger('Crawl foundRef\n' + foundRef.getText()); + while ( + ts.isIdentifier(foundRef) || + ts.isPropertyAccessExpression(foundRef) || + ts.isElementAccessExpression(foundRef) || + ts.isVariableDeclaration(foundRef) + ) { + logger('Crawl loop\n' + foundRef.getText()); + if (ts.isVariableDeclaration(foundRef)) { + if (ts.isIdentifier(foundRef.name)) { + // We have already added the paths because of the right-hand expression, + // const pokemon = result.data.pokemon --> we have pokemon as our path, + // now re-crawling pokemon for all of its accessors should deliver us the usage + // patterns... This might get expensive though if we need to perform this deeply. + return crawlScope(foundRef.name, pathParts, allFields, source, info); + } else if (ts.isObjectBindingPattern(foundRef.name)) { + // First we need to traverse the left-hand side of the variable assignment, + // this could be tree-like as we could be dealing with + // - const { x: { y: z }, a: { b: { c, d }, e: { f } } } = result.data + // Which we will need several paths for... + // after doing that we need to re-crawl all of the resulting variables + // Crawl down until we have either a leaf node or an object/array that can + // be recrawled + return traverseDestructuring( + foundRef.name, + pathParts, + allFields, + source, + info + ); + } + } else if ( + ts.isIdentifier(foundRef) && + allFields.includes(foundRef.text) && + !pathParts.includes(foundRef.text) + ) { + pathParts.push(foundRef.text); + } else if ( + ts.isPropertyAccessExpression(foundRef) && + allFields.includes(foundRef.name.text) && + !pathParts.includes(foundRef.name.text) + ) { + pathParts.push(foundRef.name.text); + } else if ( + ts.isElementAccessExpression(foundRef) && + ts.isStringLiteral(foundRef.argumentExpression) && + allFields.includes(foundRef.argumentExpression.text) && + !pathParts.includes(foundRef.argumentExpression.text) + ) { + pathParts.push(foundRef.argumentExpression.text); + } + + foundRef = foundRef.parent; + } + + logger(`Crawl results ${JSON.stringify(pathParts, undefined, 2)}`); + return pathParts.join('.'); + }); + return results; +}; + +const checkFieldUsageInFile = ( + source: ts.SourceFile, + nodes: ts.NoSubstitutionTemplateLiteral[], + info: ts.server.PluginCreateInfo +) => { + const logger: Logger = (msg: string) => + info.project.projectService.logger.info(`[GraphQLSP] ${msg}`); + const typeChecker = info.languageService.getProgram()?.getTypeChecker(); + if (!typeChecker) return []; + + const diagnostics: ts.Diagnostic[] = []; + nodes.forEach(node => { + const nodeText = node.getText(); + // Bailing for mutations/subscriptions as these could have small details + // for normalised cache interactions + if (nodeText.includes('mutation') || nodeText.includes('subscription')) + return; + + const variableDeclaration = getVariableDeclaration(node); + if (!ts.isVariableDeclaration(variableDeclaration)) return; + + const symbol = typeChecker.getSymbolAtLocation(variableDeclaration.name); + if (!symbol) return; + + const references = info.languageService.findReferences( + source.fileName, + variableDeclaration.name.pos + ); + if (!references) return; + + references[0].references.forEach(ref => { + if (ref.fileName !== source.fileName) return; + + let found = findNode(source, ref.textSpan.start); + while (found && !ts.isVariableStatement(found)) { + found = found.parent; + } + + if (!found || !ts.isVariableStatement(found)) return; + + const [output] = found.declarationList.declarations; + + if (output.name.getText() === variableDeclaration.name.getText()) return; + + let temp = output.name; + // TODO: this currently does not solve deep destructuring + // in the initial iteration this is probably not worth it and + // it might be better to support the three case that we do + // - const result = await client.query() || useFragment() + // - const [result] = useQuery() --> urql + // - const { data } = useQuery() --> Apollo + if ( + ts.isArrayBindingPattern(temp) && + ts.isBindingElement(temp.elements[0]) + ) { + temp = temp.elements[0].name; + } else if (ts.isObjectBindingPattern(temp)) { + const foundDataElement = temp.elements.find(el => { + if ( + ts.isBindingElement(el) && + ts.isIdentifier(el.name) && + el.name.text === 'data' + ) + return el; + if ( + ts.isBindingElement(el) && + el.propertyName && + ts.isIdentifier(el.propertyName) && + el.propertyName.text === 'data' + ) + return el; + }); + + if (!foundDataElement) return; + temp = foundDataElement.name; + } + const outputReferences = info.languageService.findReferences( + source.fileName, + temp.pos + ); + + if (!outputReferences) return; + + const inProgress: string[] = []; + const allPaths: string[] = []; + const allFields: string[] = []; + const reserved = ['id', '__typename']; + const fieldToLoc = new Map(); + visit(parse(node.getText().slice(1, -1)), { + Field: { + enter: node => { + allFields.push(node.name.value); + if (!node.selectionSet && !reserved.includes(node.name.value)) { + let p; + if (inProgress.length) { + p = inProgress.join('.') + '.' + node.name.value; + } else { + p = node.name.value; + } + allPaths.push(p); + + fieldToLoc.set(p, { + start: node.name.loc!.start, + length: node.name.loc!.end - node.name.loc!.start, + }); + } else if (node.selectionSet) { + inProgress.push(node.name.value); + } + }, + leave: node => { + if (node.selectionSet) { + inProgress.pop(); + } + }, + }, + }); + + const allAccess = outputReferences[0].references.flatMap(ref => { + if (ref.fileName !== source.fileName) return []; + + if ( + found!.getStart() <= ref.textSpan.start && + found!.getEnd() >= ref.textSpan.start + ref.textSpan.length + ) + return []; + + let foundRef = findNode(source, ref.textSpan.start); + if (!foundRef) return []; + + const pathParts: Array = []; + // TODO: var-assignment, object and array destructuring + // + // cases to support: + // - const { pokemon } = result.data --> re-crawl pokemon + // - const { pokemon: p } = result.data --> re-crawl p and add pokemon as the path + // - const { pokemon: { name } } = result.data --> use name as a leaf-node, no re-crawling need + // - const pokemon = result.data.pokemon --> re-crawl pokemon + // - const p = result.data.pokemon --> re-crawl p and add pokemon as the path + while ( + ts.isIdentifier(foundRef) || + ts.isPropertyAccessExpression(foundRef) || + ts.isElementAccessExpression(foundRef) || + ts.isVariableDeclaration(foundRef) + ) { + if (ts.isVariableDeclaration(foundRef)) { + if (ts.isIdentifier(foundRef.name)) { + // We have already added the paths because of the right-hand expression, + // const pokemon = result.data.pokemon --> we have pokemon as our path, + // now re-crawling pokemon for all of its accessors should deliver us the usage + // patterns... This might get expensive though if we need to perform this deeply. + logger('crawling\n' + foundRef.getText()); + const result = crawlScope( + foundRef.name, + pathParts, + allFields, + source, + info + ); + logger('crawled\n' + JSON.stringify(result, undefined, 2)); + return result; + } else if (ts.isObjectBindingPattern(foundRef.name)) { + // First we need to traverse the left-hand side of the variable assignment, + // this could be tree-like as we could be dealing with + // - const { x: { y: z }, a: { b: { c, d }, e: { f } } } = result.data + // Which we will need several paths for... + // after doing that we need to re-crawl all of the resulting variables + // Crawl down until we have either a leaf node or an object/array that can + // be recrawled + return traverseDestructuring( + foundRef.name, + pathParts, + allFields, + source, + info + ); + } + } else if ( + ts.isIdentifier(foundRef) && + allFields.includes(foundRef.text) && + !pathParts.includes(foundRef.text) + ) { + pathParts.push(foundRef.text); + } else if ( + ts.isPropertyAccessExpression(foundRef) && + allFields.includes(foundRef.name.text) && + !pathParts.includes(foundRef.name.text) + ) { + pathParts.push(foundRef.name.text); + } else if ( + ts.isElementAccessExpression(foundRef) && + ts.isStringLiteral(foundRef.argumentExpression) && + allFields.includes(foundRef.argumentExpression.text) && + !pathParts.includes(foundRef.argumentExpression.text) + ) { + pathParts.push(foundRef.argumentExpression.text); + } + + foundRef = foundRef.parent; + } + + return pathParts.join('.'); + }); + + const unused = allPaths.filter(x => !allAccess.includes(x)); + unused.forEach(unusedField => { + const loc = fieldToLoc.get(unusedField); + if (!loc) return; + + diagnostics.push({ + file: source, + length: loc.length, + start: node.getStart() + loc.start + 1, + category: ts.DiagnosticCategory.Warning, + code: UNUSED_FIELD_CODE, + messageText: `Field '${unusedField}' is not used.`, + }); + }); + }); + }); + + return diagnostics; +}; + const checkImportsForFragments = ( source: ts.SourceFile, info: ts.server.PluginCreateInfo diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb9a5270..36ec3e8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: graphql: specifier: ^16.8.1 version: 16.8.1 + urql: + specifier: ^4.0.6 + version: 4.0.6(graphql@16.8.1)(react@18.2.0) devDependencies: '@0no-co/graphqlsp': specifier: file:../graphqlsp @@ -94,6 +97,9 @@ importers: '@graphql-codegen/client-preset': specifier: ^4.1.0 version: 4.1.0(graphql@16.8.1) + '@types/react': + specifier: ^18.2.45 + version: 18.2.45 ts-node: specifier: ^10.9.1 version: 10.9.1(@types/node@18.15.11)(typescript@5.3.3) @@ -192,6 +198,17 @@ packages: graphql: 16.8.1 dev: false + /@0no-co/graphql.web@1.0.4(graphql@16.8.1): + resolution: {integrity: sha512-W3ezhHGfO0MS1PtGloaTpg0PbaT8aZSmmaerL7idtU5F7oCI+uu25k+MsMS31BVFlp4aMkHSrNRxiD72IlK8TA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + graphql: + optional: true + dependencies: + graphql: 16.8.1 + dev: false + /@ampproject/remapping@2.2.0: resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} engines: {node: '>=6.0.0'} @@ -2283,6 +2300,22 @@ packages: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: true + /@types/prop-types@15.7.11: + resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} + dev: true + + /@types/react@18.2.45: + resolution: {integrity: sha512-TtAxCNrlrBp8GoeEp1npd5g+d/OejJHFxS3OWmrPBMFaVQMSN0OFySozJio5BHxTuTeug00AVXVAjfDSfk+lUg==} + dependencies: + '@types/prop-types': 15.7.11 + '@types/scheduler': 0.16.8 + csstype: 3.1.3 + dev: true + + /@types/scheduler@0.16.8: + resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==} + dev: true + /@types/semver@7.5.4: resolution: {integrity: sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==} dev: true @@ -2311,6 +2344,15 @@ packages: - graphql dev: false + /@urql/core@4.2.2(graphql@16.8.1): + resolution: {integrity: sha512-TP1kheq9bnrEdnVbJqh0g0ZY/wfdpPeAzjiiDK+Tm+Pbi0O1Xdu6+fUJ/wJo5QpHZzkIyya4/AecG63e6scFqQ==} + dependencies: + '@0no-co/graphql.web': 1.0.4(graphql@16.8.1) + wonka: 6.3.4 + transitivePeerDependencies: + - graphql + dev: false + /@vitest/expect@0.34.6: resolution: {integrity: sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==} dependencies: @@ -2991,6 +3033,10 @@ packages: which: 2.0.2 dev: true + /csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + dev: true + /csv-generate@3.4.3: resolution: {integrity: sha512-w/T+rqR0vwvHqWs/1ZyMDWtHHSJaN06klRqJXBEpDJaM/+dZkso0OKh1VcuuYvK3XM53KysVNq8Ko/epCK8wOw==} dev: true @@ -3431,14 +3477,6 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - requiresBuild: true - dev: true - optional: true - /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4804,6 +4842,13 @@ packages: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true + /react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: false + /read-pkg-up@7.0.1: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} engines: {node: '>=8'} @@ -4939,7 +4984,7 @@ packages: engines: {node: '>=14.18.0', npm: '>=8.0.0'} hasBin: true optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /rollup@4.7.0: @@ -5572,6 +5617,18 @@ packages: resolution: {integrity: sha512-WHN8KDQblxd32odxeIgo83rdVDE2bvdkb86it7bMhYZwWKJz0+O0RK/eZiHYnM+zgt/U7hAHOlCQGfjjvSkw2g==} dev: true + /urql@4.0.6(graphql@16.8.1)(react@18.2.0): + resolution: {integrity: sha512-meXJ2puOd64uCGKh7Fse2R7gPa8+ZpBOoA62jN7CPXXUt7SVZSdeXWSpB3HvlfzLUkEqsWbvshwrgeWRYNNGaQ==} + peerDependencies: + react: '>= 16.8.0' + dependencies: + '@urql/core': 4.2.2(graphql@16.8.1) + react: 18.2.0 + wonka: 6.3.4 + transitivePeerDependencies: + - graphql + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true @@ -5808,6 +5865,10 @@ packages: resolution: {integrity: sha512-nJyGPcjuBiaLFn8QAlrHd+QjV9AlPO7snOWAhgx6aX0nQLMV6Wi0nqfrkmsXIH0efngbDOroOz2QyLnZMF16Hw==} dev: false + /wonka@6.3.4: + resolution: {integrity: sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg==} + dev: false + /wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'}