From 737d4184f3af1d8fe9d64eb1b7e23dfcfbe640ea Mon Sep 17 00:00:00 2001 From: Richard Haddad Date: Sun, 3 Jul 2022 16:36:40 +0200 Subject: [PATCH] feat: add gql call expressions support (#2509) Add ```gql(``)```, ```graphql(``)``` call expressions support for highlighting & language support - adds highlighting to grammar - create simper unit tests for just `findGraphQLTags` - removes some complex and seemingly unused legacy relay-related behaviour from `findGraphQLTags`. If you have any cases where graphql language support disappears with a nested graphql template tag/etc that worked before this, please let us know! Co-authored-by: Rikki Schulte --- .changeset/three-rice-matter.md | 7 + custom-words.txt | 1 + package.json | 2 +- .../src/__tests__/MessageProcessor-test.ts | 53 +++++ .../src/__tests__/findGraphQLTags-test.ts | 206 ++++++++++++++++++ .../src/findGraphQLTags.ts | 131 ++++------- .../vscode-graphql/grammars/graphql.js.json | 4 +- 7 files changed, 307 insertions(+), 97 deletions(-) create mode 100644 .changeset/three-rice-matter.md create mode 100644 packages/graphql-language-service-server/src/__tests__/findGraphQLTags-test.ts diff --git a/.changeset/three-rice-matter.md b/.changeset/three-rice-matter.md new file mode 100644 index 00000000000..7bfe579881b --- /dev/null +++ b/.changeset/three-rice-matter.md @@ -0,0 +1,7 @@ +--- +'vscode-graphql': patch +'graphql-language-service-server': patch +'graphql-language-service-cli': patch +--- + +Add ```gql(``)```, ```graphql(``)``` call expressions support for highlighting & language diff --git a/custom-words.txt b/custom-words.txt index e19d696d690..660c550989f 100644 --- a/custom-words.txt +++ b/custom-words.txt @@ -233,3 +233,4 @@ runtimes typeahead typeaheads unparsable +randomthing diff --git a/package.json b/package.json index d866206408e..92581ccb2c4 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "build:packages": "yarn tsc", "build:watch": "yarn tsc --watch", "watch": "yarn build:watch", - "watch-vscode": "concurrently --raw 'yarn tsc --watch' 'yarn workspace vscode-graphql run compile --watch'", + "watch-vscode": "concurrently --raw \"yarn tsc --watch\" \"yarn workspace vscode-graphql run compile --watch\"", "check": "yarn tsc --dry", "cypress-open": "yarn workspace graphiql cypress-open", "dev-graphiql": "yarn workspace graphiql dev", diff --git a/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts b/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts index 8664e033b2c..98cb5e2e3ae 100644 --- a/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts +++ b/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts @@ -563,6 +563,36 @@ query Test { `); }); + it('parseDocument finds queries in call expressions with template literals', async () => { + const text = ` +// @flow +import {gql} from 'react-apollo'; +import type {B} from 'B'; +import A from './A'; + +const QUERY = gql(\` +query Test { + test { + value + ...FragmentsComment + } +} +\${A.fragments.test} +\`); + +export function Example(arg: string) {}`; + + const contents = parseDocument(text, 'test.js'); + expect(contents[0].query).toEqual(` +query Test { + test { + value + ...FragmentsComment + } +} +`); + }); + it('parseDocument finds queries in #graphql-annotated templates', async () => { const text = ` import {gql} from 'react-apollo'; @@ -638,6 +668,29 @@ query Test { \${A.fragments.test} \` +export function Example(arg: string) {}`; + + const contents = parseDocument(text, 'test.js'); + expect(contents.length).toEqual(0); + }); + + it('parseDocument ignores non gql call expressions with template literals', async () => { + const text = ` +// @flow +import randomthing from 'package'; +import type {B} from 'B'; +import A from './A'; + +const QUERY = randomthing(\` +query Test { + test { + value + ...FragmentsComment + } +} +\${A.fragments.test} +\`); + export function Example(arg: string) {}`; const contents = parseDocument(text, 'test.js'); diff --git a/packages/graphql-language-service-server/src/__tests__/findGraphQLTags-test.ts b/packages/graphql-language-service-server/src/__tests__/findGraphQLTags-test.ts new file mode 100644 index 00000000000..f11d89a8163 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/findGraphQLTags-test.ts @@ -0,0 +1,206 @@ +/** + * Copyright (c) 2022 GraphQL Contributors + * All rights reserved. + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { tmpdir } from 'os'; + +import { findGraphQLTags as baseFindGraphQLTags } from '../findGraphQLTags'; + +jest.mock('../Logger'); + +import { Logger } from '../Logger'; + +describe('findGraphQLTags', () => { + const logger = new Logger(tmpdir()); + const findGraphQLTags = (text: string, ext: string) => + baseFindGraphQLTags(text, ext, '', logger); + + it('finds queries in tagged templates', async () => { + const text = ` +// @flow +import {gql} from 'react-apollo'; +import type {B} from 'B'; +import A from './A'; + +const QUERY = gql\` +query Test { + test { + value + ...FragmentsComment + } +} +\${A.fragments.test} +\` + +export function Example(arg: string) {}`; + + const contents = findGraphQLTags(text, '.js'); + expect(contents[0].template).toEqual(` +query Test { + test { + value + ...FragmentsComment + } +} +`); + }); + + it('finds queries in call expressions with template literals', async () => { + const text = ` + // @flow + import {gql} from 'react-apollo'; + import type {B} from 'B'; + import A from './A'; + + const QUERY = gql(\` + query Test { + test { + value + ...FragmentsComment + } + } + \${A.fragments.test} + \`); + + export function Example(arg: string) {}`; + + const contents = findGraphQLTags(text, '.js'); + expect(contents[0].template).toEqual(` + query Test { + test { + value + ...FragmentsComment + } + } + `); + }); + + it('finds queries in #graphql-annotated templates', async () => { + const text = ` +import {gql} from 'react-apollo'; +import {B} from 'B'; +import A from './A'; + +const QUERY: string = \`#graphql +query Test { + test { + value + ...FragmentsComment + } +} +\${A.fragments.test} +\` + +export function Example(arg: string) {}`; + + const contents = findGraphQLTags(text, '.ts'); + expect(contents[0].template).toEqual(`#graphql +query Test { + test { + value + ...FragmentsComment + } +} +`); + }); + + it('finds queries in /* GraphQL */ prefixed templates', async () => { + const text = ` +import {gql} from 'react-apollo'; +import {B} from 'B'; +import A from './A'; + + +const QUERY: string = +/* GraphQL */ +\` +query Test { + test { + value + ...FragmentsComment + } +} +\${A.fragments.test} +\` + +export function Example(arg: string) {}`; + + const contents = findGraphQLTags(text, '.ts'); + expect(contents[0].template).toEqual(` +query Test { + test { + value + ...FragmentsComment + } +} +`); + }); + + it('finds queries with nested template tag expressions', async () => { + const text = `export default { + else: () => gql\` query {} \` +}`; + + const contents = findGraphQLTags(text, '.ts'); + expect(contents[0].template).toEqual(` query {} `); + }); + + it('finds queries with template tags inside call expressions', async () => { + const text = `something({ + else: () => gql\` query {} \` +})`; + + const contents = findGraphQLTags(text, '.ts'); + expect(contents[0].template).toEqual(` query {} `); + }); + + it('ignores non gql tagged templates', async () => { + const text = ` +// @flow +import randomthing from 'package'; +import type {B} from 'B'; +import A from './A'; + +const QUERY = randomthing\` +query Test { + test { + value + ...FragmentsComment + } +} +\${A.fragments.test} +\` + +export function Example(arg: string) {}`; + + const contents = findGraphQLTags(text, '.js'); + expect(contents.length).toEqual(0); + }); + + it('ignores non gql call expressions with template literals', async () => { + const text = ` +// @flow +import randomthing from 'package'; +import type {B} from 'B'; +import A from './A'; + +const QUERY = randomthing(\` +query Test { + test { + value + ...FragmentsComment + } +} +\${A.fragments.test} +\`); + +export function Example(arg: string) {}`; + + const contents = findGraphQLTags(text, '.js'); + expect(contents.length).toEqual(0); + }); +}); diff --git a/packages/graphql-language-service-server/src/findGraphQLTags.ts b/packages/graphql-language-service-server/src/findGraphQLTags.ts index 215d80ea20b..1f6bc8c01ea 100644 --- a/packages/graphql-language-service-server/src/findGraphQLTags.ts +++ b/packages/graphql-language-service-server/src/findGraphQLTags.ts @@ -10,7 +10,6 @@ import { Expression, TaggedTemplateExpression, - ObjectExpression, TemplateLiteral, } from '@babel/types'; @@ -28,12 +27,6 @@ const PARSER_OPTIONS: ParserOptions = { strictMode: false, }; -const CREATE_CONTAINER_FUNCTIONS: { [key: string]: boolean } = { - createFragmentContainer: true, - createPaginationContainer: true, - createRefetchContainer: true, -}; - const DEFAULT_STABLE_TAGS = ['graphql', 'graphqls', 'gql']; export const DEFAULT_TAGS = [...DEFAULT_STABLE_TAGS, 'graphql.experimental']; @@ -100,74 +93,50 @@ export function findGraphQLTags( } const ast = parsedAST!; + const parseTemplateLiteral = (node: TemplateLiteral) => { + const loc = node.quasis[0].loc; + if (loc) { + if (node.quasis.length > 1) { + const last = node.quasis.pop(); + if (last?.loc?.end) { + loc.end = last.loc.end; + } + } + const template = + node.quasis.length > 1 + ? node.quasis.map(quasi => quasi.value.raw).join('') + : node.quasis[0].value.raw; + const range = new Range( + new Position(loc.start.line - 1, loc.start.column), + new Position(loc.end.line - 1, loc.end.column), + ); + result.push({ + tag: '', + template, + range, + }); + } + }; + const visitors = { CallExpression: (node: Expression) => { if ('callee' in node) { const callee = node.callee; + if ( - !( - (callee.type === 'Identifier' && - CREATE_CONTAINER_FUNCTIONS[callee.name]) || - (callee.type === 'MemberExpression' && - callee.object.type === 'Identifier' && - callee.object.name === 'Relay' && - callee.property.type === 'Identifier' && - CREATE_CONTAINER_FUNCTIONS[callee.property.name]) - ) + callee.type === 'Identifier' && + getGraphQLTagName(callee) && + 'arguments' in node ) { - traverse(node, visitors); - return; - } - - if ('arguments' in node) { - const fragments = node.arguments[1]; - if (fragments.type === 'ObjectExpression') { - fragments.properties.forEach( - (property: ObjectExpression['properties'][0]) => { - if ( - 'value' in property && - 'loc' in property.value && - 'tag' in property.value - ) { - const tagName = getGraphQLTagName(property.value.tag); - const template = getGraphQLText(property.value.quasi); - if (tagName && property.value.loc) { - const loc = property.value.loc; - const range = new Range( - new Position(loc.start.line - 1, loc.start.column), - new Position(loc.end.line - 1, loc.end.column), - ); - result.push({ - tag: tagName, - template, - range, - }); - } - } - }, - ); - } else if ('tag' in fragments) { - const tagName = getGraphQLTagName(fragments.tag); - const template = getGraphQLText(fragments.quasi); - if (tagName && fragments.loc) { - const loc = fragments.loc; - const range = new Range( - new Position(loc.start.line - 1, loc.start.column), - new Position(loc.end.line - 1, loc.end.column), - ); - - result.push({ - tag: tagName, - template, - range, - }); - } - } - // Visit remaining arguments - for (let ii = 2; ii < node.arguments.length; ii++) { - visit(node.arguments[ii], visitors); + const templateLiteral = node.arguments[0]; + if (templateLiteral && templateLiteral.type === 'TemplateLiteral') { + parseTemplateLiteral(templateLiteral); + return; } } + + traverse(node, visitors); + return; } }, TaggedTemplateExpression: (node: TaggedTemplateExpression) => { @@ -208,28 +177,7 @@ export function findGraphQLTags( node.leadingComments?.[0]?.value.match(/^\s*GraphQL\s*$/), ); if (hasGraphQLPrefix || hasGraphQLComment) { - const loc = node.quasis[0].loc; - if (loc) { - if (node.quasis.length > 1) { - const last = node.quasis.pop(); - if (last?.loc?.end) { - loc.end = last.loc.end; - } - } - const template = - node.quasis.length > 1 - ? node.quasis.map(quasi => quasi.value.raw).join('') - : node.quasis[0].value.raw; - const range = new Range( - new Position(loc.start.line - 1, loc.start.column), - new Position(loc.end.line - 1, loc.end.column), - ); - result.push({ - tag: '', - template, - range, - }); - } + parseTemplateLiteral(node); } }, }; @@ -267,11 +215,6 @@ function getGraphQLTagName(tag: Expression): string | null { return null; } -function getGraphQLText(quasi: TemplateLiteral) { - const quasis = quasi.quasis; - return quasis[0].value.raw; -} - function visit(node: { [key: string]: any }, visitors: TagVisitors) { const fn = visitors[node.type]; if (fn && fn != null) { diff --git a/packages/vscode-graphql/grammars/graphql.js.json b/packages/vscode-graphql/grammars/graphql.js.json index 74d409abe4d..36ad71a20d4 100644 --- a/packages/vscode-graphql/grammars/graphql.js.json +++ b/packages/vscode-graphql/grammars/graphql.js.json @@ -16,7 +16,7 @@ "patterns": [ { "contentName": "meta.embedded.block.graphql", - "begin": "\\s*+(?:(?:(Relay)\\??\\.)(QL)|(gql|graphql|graphql\\.experimental)|(/\\* GraphQL \\*/))\\s*(`)", + "begin": "\\s*+(?:(?:(Relay)\\??\\.)(QL)|(gql|graphql|graphql\\.experimental)|(/\\* GraphQL \\*/))\\s*\\(?\\s*(`)", "beginCaptures": { "1": { "name": "variable.other.class.js" @@ -44,7 +44,7 @@ }, { "contentName": "meta.embedded.block.graphql", - "begin": "\\s*+(?:(?:(Relay)\\??\\.)(QL)|(gql|graphql|graphql\\.experimental))\\s*(?:<.*>)(`)", + "begin": "\\s*+(?:(?:(Relay)\\??\\.)(QL)|(gql|graphql|graphql\\.experimental))\\s*\\(?\\s*(?:<.*>)(`)", "beginCaptures": { "1": { "name": "variable.other.class.js"