diff --git a/README.md b/README.md index d19cd861d..ad7edf79e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ This plugin has the following features: - [Plugin options](#plugin-options) - [`schema`](#schema) - [`tag`](#tag) + - [`exclude`](#exclude) + - [`enabledGlobalFragments`](#enabledglobalfragments) - [`localSchemaExtensions`](#localschemaextensions) - [`typegen.addons`](#typegenaddons) - [`removeDuplicatedFragments`](#removeduplicatedfragments) @@ -121,6 +123,7 @@ Pass plugin options to your tsconfig.json to configure this plugin. /* plugin options */ "schema": "path-or-url-to-your-schema.graphql", "tag": "gql", + "exclude": ["__generated__"], ... } ] @@ -270,6 +273,22 @@ const str3 = otherTagFn`foooo`; // don't work It's useful to write multiple kinds template strings(e.g. one is Angular Component template, another is Apollo GraphQL query). +### `exclude` + +It's optional. Specify an array of file or directory names when you want to exclude specific TypeScript sources from the plugin's analysis. + +It's useful if other code generator copies your GraphQL Template Strings. + +> [!NOTE] +> Currently, the `exclude` option only accepts file or directory names. Wildcard characters such as `*` and `**` are not allowed. + +### `enabledGlobalFragments` + +It's optional and the default value is `false`. If enabled, the plugin automatically searches for and merges the dependent fragments for the target GraphQL operation in the TypeScript project. + +> [!IMPORTANT] +> Fragments must be given a unique name if this option is enabled. + ### `localSchemaExtensions` It's optional. If you want to extend server-side schema, derived from `schema` option, you can set path of SDL file of your local extension. diff --git a/e2e/lang-server-specs/diagnostics-syntax.js b/e2e/lang-server-specs/diagnostics-syntax.js index 8d8055ec5..61267f098 100644 --- a/e2e/lang-server-specs/diagnostics-syntax.js +++ b/e2e/lang-server-specs/diagnostics-syntax.js @@ -7,8 +7,7 @@ function findResponse(responses, eventName) { const fileContent = ` import gql from 'graphql-tag'; -const q = gql\` -\`; +const q = gql\`{\`; `; async function run(server) { @@ -21,7 +20,7 @@ async function run(server) { const semanticDiagEvent = findResponse(server.responses, 'semanticDiag'); assert(!!semanticDiagEvent); assert.strictEqual(semanticDiagEvent.body.diagnostics.length, 1); - assert.strictEqual(semanticDiagEvent.body.diagnostics[0].text, 'Syntax Error: Unexpected .'); + assert.strictEqual(semanticDiagEvent.body.diagnostics[0].text, 'Syntax Error: Expected Name, found .'); }); } diff --git a/e2e/lang-server-specs/diagnostics-with-update.js b/e2e/lang-server-specs/diagnostics-with-update.js new file mode 100644 index 000000000..9dd27de06 --- /dev/null +++ b/e2e/lang-server-specs/diagnostics-with-update.js @@ -0,0 +1,69 @@ +const assert = require('assert'); +const path = require('path'); +const { mark } = require('fretted-strings'); + +function findResponse(responses, eventName) { + return responses.find(response => response.event === eventName); +} + +async function run(server) { + const fileFragments = path.resolve(__dirname, '../../project-fixtures/simple-prj/fragments.ts'); + const fileFragmentsContent = ` +import gql from 'graphql-tag'; +const f = gql\`fragment MyFragment on Query { hello }\`; + `; + + const fileMain = path.resolve(__dirname, '../../project-fixtures/simple-prj/main.ts'); + const frets = {}; + const fileMainContent = mark( + ` +import gql from 'graphql-tag'; +const q = gql\`query MyQuery { }\`; +%%% \\ ^ %%% +%%% \\ p %%% + `, + frets, + ); + + server.send({ + command: 'open', + arguments: { file: fileFragments, fileContent: fileFragmentsContent, scriptKindName: 'TS' }, + }); + server.send({ command: 'open', arguments: { file: fileMain, fileContent: fileMainContent, scriptKindName: 'TS' } }); + + await server.waitEvent('projectLoadingFinish'); + + server.send({ + command: 'updateOpen', + arguments: { + changedFiles: [ + { + fileName: fileMain, + textChanges: [ + { + newText: '...MyFragment', + start: { + line: frets.p.line + 1, + offset: frets.p.character + 1, + }, + end: { + line: frets.p.line + 1, + offset: frets.p.character + 1, + }, + }, + ], + }, + ], + }, + }); + await server.waitResponse('updateOpen'); + server.send({ command: 'geterr', arguments: { files: [fileMain], delay: 0 } }); + await server.waitEvent('semanticDiag'); + return server.close().then(() => { + const semanticDiagEvent = findResponse(server.responses, 'semanticDiag'); + assert(!!semanticDiagEvent); + assert.equal(semanticDiagEvent.body.diagnostics.length, 0); + }); +} + +module.exports = run; diff --git a/project-fixtures/gql-errors-prj/main.ts b/project-fixtures/gql-errors-prj/main.ts index 4293a203c..a4be8414e 100644 --- a/project-fixtures/gql-errors-prj/main.ts +++ b/project-fixtures/gql-errors-prj/main.ts @@ -13,10 +13,14 @@ const tooComplexExpressionQuery = gql` ${getField()} } ` +const semanticErrorFragment = gql` + fragment MyFragment on Query { + hoge + } +`; -const semanticErrorQUery = gql` - query { - helo - helloWorld +const duplicatedFragment = gql` + fragment MyFragment on Query { + hoge } `; diff --git a/project-fixtures/gql-errors-prj/tsconfig.json b/project-fixtures/gql-errors-prj/tsconfig.json index 712966dde..8be2940e9 100644 --- a/project-fixtures/gql-errors-prj/tsconfig.json +++ b/project-fixtures/gql-errors-prj/tsconfig.json @@ -7,6 +7,7 @@ "name": "ts-graphql-plugin", "tag": "gql", "schema": "schema.graphql", + "enabledGlobalFragments": true, "localSchemaExtensions": ["local-extension.graphql"] } ] diff --git a/project-fixtures/react-apollo-prj/GRAPHQL_OPERATIONS.md b/project-fixtures/react-apollo-prj/GRAPHQL_OPERATIONS.md index 1d4738a59..63f582df9 100644 --- a/project-fixtures/react-apollo-prj/GRAPHQL_OPERATIONS.md +++ b/project-fixtures/react-apollo-prj/GRAPHQL_OPERATIONS.md @@ -4,10 +4,6 @@ ### GitHubQuery ```graphql -fragment RepositoryFragment on Repository { - description -} - query GitHubQuery($first: Int!) { viewer { repositories(first: $first) { @@ -18,9 +14,13 @@ query GitHubQuery($first: Int!) { } } } + +fragment RepositoryFragment on Repository { + description +} ``` -From [src/index.tsx:11:19](src/index.tsx#L11-L23) +From [src/index.tsx:11:19](src/index.tsx#L11-L22) ## Mutations @@ -34,7 +34,7 @@ mutation UpdateMyRepository($repositoryId: ID!) { } ``` -From [src/index.tsx:25:22](src/index.tsx#L25-L31) +From [src/index.tsx:24:22](src/index.tsx#L24-L30) ## Fragments diff --git a/project-fixtures/react-apollo-prj/tsconfig.json b/project-fixtures/react-apollo-prj/tsconfig.json index 755fcf874..fd0b14701 100644 --- a/project-fixtures/react-apollo-prj/tsconfig.json +++ b/project-fixtures/react-apollo-prj/tsconfig.json @@ -12,8 +12,9 @@ "name": "ts-graphql-plugin", "schema": "schema.graphql", "tag": "gql", + "exclude": ["src/__generated__"], "typegen": { - "addons": ["../../addons/typed-query-document"] + "addons": ["ts-graphql-plugin/addons/typed-query-document"] } } ] diff --git a/project-fixtures/simple-prj/fragments.ts b/project-fixtures/simple-prj/fragments.ts new file mode 100644 index 000000000..e69de29bb diff --git a/project-fixtures/simple-prj/tsconfig.json b/project-fixtures/simple-prj/tsconfig.json index f7031bd36..a65ed5cb4 100644 --- a/project-fixtures/simple-prj/tsconfig.json +++ b/project-fixtures/simple-prj/tsconfig.json @@ -7,6 +7,7 @@ "name": "ts-graphql-plugin", "tag": "gql", "schema": "schema.graphql", + "enabledGlobalFragments": true, "localSchemaExtensions": ["local-extension.graphql"], "typegen": { "addons": ["./addon"] diff --git a/project-fixtures/typegen-addon-prj/tsconfig.json b/project-fixtures/typegen-addon-prj/tsconfig.json index 487b1ee79..524fca4c3 100644 --- a/project-fixtures/typegen-addon-prj/tsconfig.json +++ b/project-fixtures/typegen-addon-prj/tsconfig.json @@ -9,6 +9,7 @@ "_name": "ts-graphql-plugin", "tag": "gql", "schema": "schema.graphql", + "enabledGlobalFragments": true, "typegen": { "addons": ["./addon"] } diff --git a/src/analyzer/__snapshots__/analyzer.test.ts.snap b/src/analyzer/__snapshots__/analyzer.test.ts.snap index 8ab047c3f..746b8c86f 100644 --- a/src/analyzer/__snapshots__/analyzer.test.ts.snap +++ b/src/analyzer/__snapshots__/analyzer.test.ts.snap @@ -5,12 +5,42 @@ exports[`Analyzer extractToManifest should extract manifest 1`] = ` [], { "documents": [ + { + "body": "fragment MyFragment on Query { + hello +}", + "documentEnd": { + "character": 59, + "line": 0, + }, + "documentStart": { + "character": 21, + "line": 0, + }, + "fileName": "fragment.ts", + "fragmentName": "MyFragment", + "operationName": undefined, + "tag": "gql", + "templateLiteralNodeEnd": { + "character": 60, + "line": 0, + }, + "templateLiteralNodeStart": { + "character": 20, + "line": 0, + }, + "type": "fragment", + }, { "body": "query MyQuery { + ...MyFragment +} + +fragment MyFragment on Query { hello }", "documentEnd": { - "character": 41, + "character": 49, "line": 0, }, "documentStart": { @@ -22,7 +52,7 @@ exports[`Analyzer extractToManifest should extract manifest 1`] = ` "operationName": "MyQuery", "tag": "gql", "templateLiteralNodeEnd": { - "character": 42, + "character": 50, "line": 0, }, "templateLiteralNodeStart": { @@ -44,6 +74,10 @@ exports[`Analyzer report should create markdown report 1`] = ` \`\`\`graphql query MyQuery { + ...MyFragment +} + +fragment MyFragment on Query { hello } \`\`\` @@ -75,15 +109,34 @@ Extracted by [ts-graphql-plugin](https://github.com/Quramy/ts-graphql-plugin)" exports[`Analyzer typegen should create type files 1`] = ` "/* eslint-disable */ /* This is an autogenerated file. Do not edit this file directly! */ -export type MyQuery = { +export type MyFragment = { hello: string; }; +" +`; + +exports[`Analyzer typegen should create type files 2`] = ` +"/* eslint-disable */ +/* This is an autogenerated file. Do not edit this file directly! */ +export type MyQuery = MyFragment; export type MyQueryVariables = {}; +export type MyFragment = { + hello: string; +}; " `; exports[`Analyzer typegen should report error when no schema 1`] = `"No GraphQL schema. Confirm your ts-graphql-plugin's "schema" configuration at tsconfig.json's compilerOptions.plugins section."`; +exports[`Analyzer validate should report duplicatedFragmentDefinitions error 1`] = ` +[ + [ErrorWithLocation: All fragments must have an unique name.], + [ErrorWithLocation: All fragments must have an unique name.], +] +`; + exports[`Analyzer validate should report error when no schema 1`] = `"No GraphQL schema. Confirm your ts-graphql-plugin's "schema" configuration at tsconfig.json's compilerOptions.plugins section."`; +exports[`Analyzer validate should report missing external fragment refference 1`] = `"Unknown fragment "F2"."`; + exports[`Analyzer validate should validate project with schema error project 1`] = `"Syntax Error: Unexpected Name "hogehoge"."`; diff --git a/src/analyzer/analyzer.test.ts b/src/analyzer/analyzer.test.ts index 8d03a1ea8..1f3c9616a 100644 --- a/src/analyzer/analyzer.test.ts +++ b/src/analyzer/analyzer.test.ts @@ -3,6 +3,7 @@ import { createTestingLanguageServiceAndHost } from '../ts-ast-util/testing/test import { createTestingSchemaManagerHost } from '../schema-manager/testing/testing-schema-manager-host'; import { SchemaManagerFactory } from '../schema-manager/schema-manager-factory'; import { TsGraphQLPluginConfig } from './types'; +import { ErrorWithLocation } from '../errors'; type CreateTestingAnalyzerOptions = { sdl: string; @@ -20,6 +21,7 @@ function createTestingAnalyzer({ files: sourceFiles = [], sdl, localSchemaExtens const pluginConfig: TsGraphQLPluginConfig = { name: 'ts-graphql-plugin', schema: '/schema.graphql', + enabledGlobalFragments: true, localSchemaExtensions: localSchemaExtension ? [localSchemaExtension.fileName] : [], removeDuplicatedFragments: true, tag: 'gql', @@ -43,9 +45,9 @@ function createTestingAnalyzer({ files: sourceFiles = [], sdl, localSchemaExtens const simpleSources = { sdl: ` - type Query { - hello: String! - } + type Query { + hello: String! + } `, files: [ { @@ -55,6 +57,47 @@ const simpleSources = { ], }; +const fragmentPrj = { + sdl: ` + type Query { + hello: String! + } + `, + files: [ + { + fileName: 'fragment.ts', + content: 'const fragment = gql`fragment MyFragment on Query { hello }`;', + }, + { + fileName: 'main.ts', + content: 'const query = gql`query MyQuery { ...MyFragment }`;', + }, + ], +}; + +const fragmentExpressionPrj = { + sdl: ` + type Query { + hello: String! + } + `, + files: [ + { + fileName: 'main.ts', + content: ` + const fragment = gql\` + fragment MyFragment on Query { hello } + \`; + + const query = gql\` + \${fragment} + query MyQuery { ...MyFragment } + \`; + `, + }, + ], +}; + const noSchemaPrj = { sdl: '', files: [ @@ -67,9 +110,9 @@ const noSchemaPrj = { const extensionErrorPrj = { sdl: ` - type Query { - hello: String! - } + type Query { + hello: String! + } `, files: [ { @@ -85,9 +128,9 @@ const extensionErrorPrj = { const semanticErrorPrj = { sdl: ` - type Query { - hello: String! - } + type Query { + hello: String! + } `, files: [ { @@ -99,9 +142,9 @@ const semanticErrorPrj = { const semanticWarningPrj = { sdl: ` - type Query { - hello: String! @deprecated(reason: "don't use") - } + type Query { + hello: String! @deprecated(reason: "don't use") + } `, files: [ { @@ -111,10 +154,77 @@ const semanticWarningPrj = { ], }; +const externalFragmentErrorPrj = { + sdl: ` + type Query { + hello: String! + } + `, + files: [ + { + fileName: 'main.ts', + content: ` + const f1 = gql\`fragment F1 on Query { __typename }\`; + const query = gql\`query MyQuery { ...F1, ...F2 }\`; + `, + }, + ], +}; + +const dependendtFragmentErrorPrj = { + sdl: ` + type Query { + hello: String! + } + `, + files: [ + { + fileName: 'fragment1.ts', + content: ` + const dependendtFragment = gql\` + fragment DependentFragment on Query { + __typename + notExistingFeild + } + \`; + `, + }, + { + fileName: 'main.ts', + content: ` + const fragment = gql\` + fragment MyFragment on Query { + hello + ...DependentFragment + } + \`; + `, + }, + ], +}; + +const duplicatedFragmentsErrorPrj = { + sdl: ` + type Query { + hello: String! + } + `, + files: [ + { + fileName: 'main.ts', + content: ` + const f1 = gql\`fragment F on Query { __typename }\`; + const f2 = gql\`fragment F on Query { __typename }\`; + const f3 = gql\` \${f1} fragment F3 on Query { ...F }\`; + `, + }, + ], +}; + describe(Analyzer, () => { describe(Analyzer.prototype.extractToManifest, () => { it('should extract manifest', () => { - const analyzer = createTestingAnalyzer(simpleSources); + const analyzer = createTestingAnalyzer(fragmentPrj); expect(analyzer.extractToManifest()).toMatchSnapshot(); }); }); @@ -127,6 +237,13 @@ describe(Analyzer, () => { expect(schema).toBeTruthy(); }); + it('should validate project using global fragments', async () => { + const analyzer = createTestingAnalyzer(fragmentPrj); + const { errors, schema } = await analyzer.validate(); + expect(errors.length).toBe(0); + expect(schema).toBeTruthy(); + }); + it('should report error when no schema', async () => { const analyzer = createTestingAnalyzer(noSchemaPrj); const { errors } = await analyzer.validate(); @@ -149,17 +266,49 @@ describe(Analyzer, () => { expect(schema).toBeTruthy(); }); - it('should validate project with semantic error project', async () => { + it('should validate project with semantic warning project', async () => { const analyzer = createTestingAnalyzer(semanticWarningPrj); const { errors, schema } = await analyzer.validate(); expect(errors.length).toBe(1); expect(schema).toBeTruthy(); }); + + it('should report missing external fragment refference', async () => { + const analyzer = createTestingAnalyzer(externalFragmentErrorPrj); + const { errors, schema } = await analyzer.validate(); + expect(errors.length).toBe(1); + expect(errors[0].message).toMatchSnapshot(); + expect(schema).toBeTruthy(); + }); + + it('should report correct dependent fragment errors', async () => { + const analyzer = createTestingAnalyzer(dependendtFragmentErrorPrj); + const { errors } = await analyzer.validate(); + expect(errors.length).toBe(1); + if (!(errors[0] instanceof ErrorWithLocation)) { + return fail(); + } + expect(errors[0].errorContent.fileName).not.toBe('main.ts'); + expect(errors[0].errorContent.fileName).toBe('fragment1.ts'); + }); + + it('should work with fragments in template expression', async () => { + const analyzer = createTestingAnalyzer(fragmentExpressionPrj); + const { errors } = await analyzer.validate(); + expect(errors.length).toBe(0); + }); + + it('should report duplicatedFragmentDefinitions error', async () => { + const analyzer = createTestingAnalyzer(duplicatedFragmentsErrorPrj); + const { errors } = await analyzer.validate(); + expect(errors.length).toBe(2); + expect(errors).toMatchSnapshot(); + }); }); describe(Analyzer.prototype.report, () => { it('should create markdown report', () => { - const analyzer = createTestingAnalyzer(simpleSources); + const analyzer = createTestingAnalyzer(fragmentPrj); const [errors, output] = analyzer.report('out.md'); expect(errors.length).toBe(0); expect(output).toMatchSnapshot(); @@ -183,12 +332,14 @@ describe(Analyzer, () => { }); it('should create type files', async () => { - const analyzer = createTestingAnalyzer(simpleSources); + const analyzer = createTestingAnalyzer(fragmentPrj); const { outputSourceFiles } = await analyzer.typegen(); if (!outputSourceFiles) return fail(); - expect(outputSourceFiles.length).toBe(1); - expect(outputSourceFiles[0].fileName.endsWith('__generated__/my-query.ts')).toBeTruthy(); + expect(outputSourceFiles.length).toBe(2); + expect(outputSourceFiles[0].fileName.endsWith('__generated__/my-fragment.ts')).toBeTruthy(); expect(outputSourceFiles[0].content).toMatchSnapshot(); + expect(outputSourceFiles[1].fileName.endsWith('__generated__/my-query.ts')).toBeTruthy(); + expect(outputSourceFiles[1].content).toMatchSnapshot(); }); }); }); diff --git a/src/analyzer/analyzer.ts b/src/analyzer/analyzer.ts index ea37af479..ac15acd1c 100644 --- a/src/analyzer/analyzer.ts +++ b/src/analyzer/analyzer.ts @@ -2,7 +2,14 @@ import ts from 'typescript'; import path from 'path'; import { ScriptSourceHelper } from '../ts-ast-util/types'; import { Extractor } from './extractor'; -import { createScriptSourceHelper } from '../ts-ast-util'; +import { + createScriptSourceHelper, + hasTagged, + findAllNodes, + registerDocumentChangeEvent, + getSanitizedTemplateText, +} from '../ts-ast-util'; +import { FragmentRegistry } from '../gql-ast-util'; import { SchemaManager, SchemaBuildErrorInfo } from '../schema-manager/schema-manager'; import { TsGqlError, ErrorWithLocation, ErrorWithoutLocation } from '../errors'; import { location2pos } from '../string-util'; @@ -10,6 +17,7 @@ import { validate } from './validator'; import { ManifestOutput, TsGraphQLPluginConfig } from './types'; import { MarkdownReporter } from './markdown-reporter'; import { TypeGenerator } from './type-generator'; +import { createFileNameFilter } from '../ts-ast-util/file-name-filter'; class TsGqlConfigError extends ErrorWithoutLocation { constructor() { @@ -47,14 +55,25 @@ export class Analyzer { private readonly _schemaManager: SchemaManager, private readonly _debug: (msg: string) => void, ) { - const langService = ts.createLanguageService(this._languageServiceHost); - this._scriptSourceHelper = createScriptSourceHelper({ - languageService: langService, - languageServiceHost: this._languageServiceHost, - }); + const documentRegistry = ts.createDocumentRegistry(); + const langService = ts.createLanguageService(this._languageServiceHost, documentRegistry); + const fragmentRegistry = new FragmentRegistry(); + const projectName = path.join(this._prjRootPath, 'tsconfig.json'); + const isExcluded = createFileNameFilter({ specs: this._pluginConfig.exclude, projectName }); + this._scriptSourceHelper = createScriptSourceHelper( + { + languageService: langService, + languageServiceHost: this._languageServiceHost, + project: { + getProjectName: () => projectName, + }, + }, + { exclude: this._pluginConfig.exclude }, + ); this._extractor = new Extractor({ removeDuplicatedFragments: this._pluginConfig.removeDuplicatedFragments === false ? false : true, scriptSourceHelper: this._scriptSourceHelper, + fragmentRegistry, debug: this._debug, }); this._typeGenerator = new TypeGenerator({ @@ -64,6 +83,26 @@ export class Analyzer { addonFactories: this._pluginConfig.typegen.addonFactories, debug: this._debug, }); + if (this._pluginConfig.enabledGlobalFragments === true) { + const tag = this._pluginConfig.tag; + registerDocumentChangeEvent(documentRegistry, { + onAcquire: (fileName, sourceFile, version) => { + if (!isExcluded(fileName) && this._languageServiceHost.getScriptFileNames().includes(fileName)) { + fragmentRegistry.registerDocuments( + fileName, + version, + findAllNodes(sourceFile, node => { + if (tag && ts.isTaggedTemplateExpression(node) && hasTagged(node, tag, sourceFile)) { + return node.template; + } else if (ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node)) { + return node; + } + }).map(node => getSanitizedTemplateText(node, sourceFile)), + ); + } + }, + }); + } } getPluginConfig() { @@ -88,13 +127,13 @@ export class Analyzer { async validate() { const [schemaErrors, schema] = await this._getSchema(); if (!schema) return { errors: schemaErrors }; - const [extractedErrors, results] = this.extract(); + const [extractedErrors, result] = this.extract(); if (extractedErrors.length) { this._debug(`Found ${extractedErrors.length} extraction errors.`); } return { - errors: [...schemaErrors, ...extractedErrors, ...validate(results, schema)], - extractedResults: results, + errors: [...schemaErrors, ...extractedErrors, ...validate(result, schema)], + extractedResults: result, schema, }; } diff --git a/src/analyzer/extractor.test.ts b/src/analyzer/extractor.test.ts index d22db6b46..b159c4f91 100644 --- a/src/analyzer/extractor.test.ts +++ b/src/analyzer/extractor.test.ts @@ -25,7 +25,7 @@ describe(Extractor, () => { }, ]); const result = extractor.extract(['main.ts'], 'gql'); - expect(result.map(r => print(r.documentNode!))).toMatchSnapshot(); + expect(result.fileEntries.map(r => print(r.documentNode!))).toMatchSnapshot(); }); it('should extract GraphQL documents and shrink duplicated fragments when removeDuplicatedFragments: true', () => { @@ -52,7 +52,7 @@ describe(Extractor, () => { true, ); const result = extractor.extract(['main.ts'], 'gql'); - expect(result.map(r => print(r.documentNode!))).toMatchSnapshot(); + expect(result.fileEntries.map(r => print(r.documentNode!))).toMatchSnapshot(); }); it('should extract GraphQL documents and shrink duplicated fragments when removeDuplicatedFragments: false', () => { @@ -79,7 +79,7 @@ describe(Extractor, () => { false, ); const result = extractor.extract(['main.ts'], 'gql'); - expect(result.map(r => print(r.documentNode!))).toMatchSnapshot(); + expect(result.fileEntries.map(r => print(r.documentNode!))).toMatchSnapshot(); }); it('should store template resolve errors with too complex interpolation', () => { @@ -103,9 +103,9 @@ describe(Extractor, () => { }, ]); const result = extractor.extract(['main.ts'], 'gql'); - expect(result[0].resolevedTemplateInfo).toBeTruthy(); - expect(result[1].resolevedTemplateInfo).toBeFalsy(); - expect(result[1].resolveTemplateError).toMatchSnapshot(); + expect(result.fileEntries[0].resolevedTemplateInfo).toBeTruthy(); + expect(result.fileEntries[1].resolevedTemplateInfo).toBeFalsy(); + expect(result.fileEntries[1].resolveTemplateError).toMatchSnapshot(); }); it('should store GraphQL syntax errors with invalid document', () => { @@ -122,7 +122,7 @@ describe(Extractor, () => { }, ]); const result = extractor.extract(['main.ts'], 'gql'); - expect(result[0].graphqlError).toBeTruthy(); + expect(result.fileEntries[0].graphqlError).toBeTruthy(); }); it('should convert results to manifest JSON', () => { @@ -168,8 +168,8 @@ describe(Extractor, () => { `, }, ]); - const result = extractor.extract(['main.ts'], 'gql') as ExtractSucceededResult[]; - const { type, operationName } = extractor.getDominantDefinition(result[0]); + const result = extractor.extract(['main.ts'], 'gql') as { fileEntries: ExtractSucceededResult[] }; + const { type, operationName } = extractor.getDominantDefinition(result.fileEntries[0]); expect(type).toBe('query'); expect(operationName).toBe('MyQuery'); }); @@ -190,8 +190,8 @@ describe(Extractor, () => { `, }, ]); - const result = extractor.extract(['main.ts'], 'gql') as ExtractSucceededResult[]; - const { type, operationName } = extractor.getDominantDefinition(result[0]); + const result = extractor.extract(['main.ts'], 'gql') as { fileEntries: ExtractSucceededResult[] }; + const { type, operationName } = extractor.getDominantDefinition(result.fileEntries[0]); expect(type).toBe('mutation'); expect(operationName).toBe('MyMutation'); }); @@ -212,8 +212,8 @@ describe(Extractor, () => { `, }, ]); - const result = extractor.extract(['main.ts'], 'gql') as ExtractSucceededResult[]; - const { type, operationName } = extractor.getDominantDefinition(result[0]); + const result = extractor.extract(['main.ts'], 'gql') as { fileEntries: ExtractSucceededResult[] }; + const { type, operationName } = extractor.getDominantDefinition(result.fileEntries[0]); expect(type).toBe('subscription'); expect(operationName).toBe('MySubscription'); }); @@ -239,8 +239,8 @@ describe(Extractor, () => { `, }, ]); - const result = extractor.extract(['main.ts'], 'gql') as ExtractSucceededResult[]; - const { type, operationName } = extractor.getDominantDefinition(result[0]); + const result = extractor.extract(['main.ts'], 'gql') as { fileEntries: ExtractSucceededResult[] }; + const { type, operationName } = extractor.getDominantDefinition(result.fileEntries[0]); expect(type).toBe('complex'); expect(operationName).toBe('MULTIPLE_OPERATIONS'); }); @@ -259,8 +259,8 @@ describe(Extractor, () => { `, }, ]); - const result = extractor.extract(['main.ts'], 'gql') as ExtractSucceededResult[]; - const { type, fragmentName } = extractor.getDominantDefinition(result[0]); + const result = extractor.extract(['main.ts'], 'gql') as { fileEntries: ExtractSucceededResult[] }; + const { type, fragmentName } = extractor.getDominantDefinition(result.fileEntries[0]); expect(type).toBe('fragment'); expect(fragmentName).toBe('MyFragment'); }); @@ -283,8 +283,8 @@ describe(Extractor, () => { `, }, ]); - const result = extractor.extract(['main.ts'], 'gql') as ExtractSucceededResult[]; - const { type, fragmentName } = extractor.getDominantDefinition(result[0]); + const result = extractor.extract(['main.ts'], 'gql') as { fileEntries: ExtractSucceededResult[] }; + const { type, fragmentName } = extractor.getDominantDefinition(result.fileEntries[0]); expect(type).toBe('fragment'); expect(fragmentName).toBe('MyFragment'); }); @@ -311,8 +311,8 @@ describe(Extractor, () => { `, }, ]); - const result = extractor.extract(['main.ts'], 'gql') as ExtractSucceededResult[]; - const { type, fragmentName } = extractor.getDominantDefinition(result[0]); + const result = extractor.extract(['main.ts'], 'gql') as { fileEntries: ExtractSucceededResult[] }; + const { type, fragmentName } = extractor.getDominantDefinition(result.fileEntries[0]); expect(type).toBe('fragment'); expect(fragmentName).toBe('MyFragment2'); }); diff --git a/src/analyzer/extractor.ts b/src/analyzer/extractor.ts index e0415a523..3a1b980b0 100644 --- a/src/analyzer/extractor.ts +++ b/src/analyzer/extractor.ts @@ -1,14 +1,29 @@ import ts from 'typescript'; -import { parse, print, DocumentNode, GraphQLError } from 'graphql'; -import { visit } from 'graphql/language'; +import { + parse, + visit, + print, + GraphQLError, + Kind, + type ASTNode, + type DocumentNode, + type FragmentDefinitionNode, +} from 'graphql'; +import { getFragmentDependenciesForAST } from 'graphql-language-service'; import { isTagged, ScriptSourceHelper, ResolvedTemplateInfo } from '../ts-ast-util'; import { ManifestOutput, ManifestDocumentEntry, OperationType } from './types'; import { ErrorWithLocation, ERROR_CODES } from '../errors'; -import { detectDuplicatedFragments } from '../gql-ast-util'; +import { + detectDuplicatedFragments, + FragmentRegistry, + getFragmentNamesInDocument, + cloneFragmentMap, +} from '../gql-ast-util'; export type ExtractorOptions = { removeDuplicatedFragments: boolean; scriptSourceHelper: ScriptSourceHelper; + fragmentRegistry: FragmentRegistry; debug: (msg: string) => void; }; @@ -45,24 +60,37 @@ export type ExtractSucceededResult = { resolveTemplateErrorMessage: undefined; }; -export type ExtractResult = ExtractTemplateResolveErrorResult | ExtractGraphQLErrorResult | ExtractSucceededResult; +export type ExtractFileResult = ExtractTemplateResolveErrorResult | ExtractGraphQLErrorResult | ExtractSucceededResult; + +export type ExtractResult = { + fileEntries: ExtractFileResult[]; + globalFragments: { + definitions: FragmentDefinitionNode[]; + definitionMap: Map; + duplicatedDefinitions: Set; + }; +}; export class Extractor { private readonly _removeDuplicatedFragments: boolean; private readonly _helper: ScriptSourceHelper; + private readonly _fragmentRegistry: FragmentRegistry; private readonly _debug: (msg: string) => void; - constructor({ debug, removeDuplicatedFragments, scriptSourceHelper }: ExtractorOptions) { + constructor({ debug, removeDuplicatedFragments, fragmentRegistry, scriptSourceHelper }: ExtractorOptions) { this._removeDuplicatedFragments = removeDuplicatedFragments; + this._fragmentRegistry = fragmentRegistry; this._helper = scriptSourceHelper; this._debug = debug; } - extract(files: string[], tagName?: string): ExtractResult[] { - const results: ExtractResult[] = []; + extract(files: string[], tagName?: string): ExtractResult { + const results: ExtractFileResult[] = []; + const targetFiles = files.filter(fileName => !this._helper.isExcluded(fileName)); this._debug('Extract template literals from: '); - this._debug(files.map(f => ' ' + f).join(',\n')); - files.forEach(fileName => { + this._debug(targetFiles.map(f => ' ' + f).join(',\n')); + targetFiles.forEach(fileName => { + if (this._helper.isExcluded(fileName)) return; const nodes = this._helper .getAllNodes(fileName, node => ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)) .filter(node => (tagName ? isTagged(node, tagName) : true)) as ( @@ -97,7 +125,7 @@ export class Extractor { } }); }); - return results.map(result => { + const fileEntries = results.map(result => { if (!result.resolevedTemplateInfo) return result; try { const rawDocumentNode = parse(result.resolevedTemplateInfo.combinedText); @@ -129,10 +157,21 @@ export class Extractor { } } }); + + const globalDefinitonsWithMap = this._fragmentRegistry.getFragmentDefinitionsWithMap(); + + return { + fileEntries, + globalFragments: { + definitions: globalDefinitonsWithMap.definitions, + definitionMap: globalDefinitonsWithMap.map, + duplicatedDefinitions: this._fragmentRegistry.getDuplicaterdFragmentDefinitions(), + }, + }; } pickupErrors( - extractResults: ExtractResult[], + { fileEntries: extractResults }: ExtractResult, { ignoreGraphQLError }: { ignoreGraphQLError: boolean } = { ignoreGraphQLError: false }, ) { const errors: ErrorWithLocation[] = []; @@ -213,7 +252,42 @@ export class Extractor { return { type, operationName, fragmentName: noReferedFragmentNames[noReferedFragmentNames.length - 1] }; } - toManifest(extractResults: ExtractResult[], tagName: string = ''): ManifestOutput { + inflateDocument( + fileEntry: ExtractSucceededResult, + { globalFragments }: { globalFragments: { definitionMap: Map } }, + ) { + const externalFragments = getFragmentDependenciesForAST( + fileEntry.documentNode, + cloneFragmentMap(globalFragments.definitionMap, getFragmentNamesInDocument(fileEntry.documentNode)), + ); + const inflatedDocumentNode: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [...fileEntry.documentNode.definitions, ...externalFragments], + loc: fileEntry.documentNode.loc, + }; + const additionalDocuments: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: externalFragments, + }; + const isDefinedExternal = (node: ASTNode) => { + let found = false; + visit(additionalDocuments, { + enter: n => { + found = node === n; + }, + }); + return found; + }; + + return { + inflatedDocumentNode, + externalFragments, + additionalDocuments, + isDefinedExternal, + }; + } + + toManifest({ fileEntries: extractResults, globalFragments }: ExtractResult, tagName: string = ''): ManifestOutput { const documents = extractResults .filter(r => !!r.documentNode) .map(result => { @@ -224,7 +298,7 @@ export class Extractor { type: type || 'other', operationName, fragmentName, - body: print(r.documentNode!), + body: print(this.inflateDocument(r, { globalFragments }).inflatedDocumentNode), tag: tagName, templateLiteralNodeStart: this._helper.getLineAndChar(r.fileName, r.templateNode.getStart()), templateLiteralNodeEnd: this._helper.getLineAndChar(r.fileName, r.templateNode.getEnd()), diff --git a/src/analyzer/index.ts b/src/analyzer/index.ts index 432645ef1..0b9104ad4 100644 --- a/src/analyzer/index.ts +++ b/src/analyzer/index.ts @@ -2,7 +2,7 @@ export * from './types'; export { Analyzer } from './analyzer'; export { AnalyzerFactory } from './analyzer-factory'; export type { - ExtractResult, + ExtractFileResult, ExtractSucceededResult, ExtractGraphQLErrorResult, ExtractTemplateResolveErrorResult, diff --git a/src/analyzer/markdown-reporter.test.ts b/src/analyzer/markdown-reporter.test.ts index 1fae262b9..f45d8a856 100644 --- a/src/analyzer/markdown-reporter.test.ts +++ b/src/analyzer/markdown-reporter.test.ts @@ -1,21 +1,9 @@ -import { Extractor } from './extractor'; import { MarkdownReporter } from './markdown-reporter'; -import { createTestingLanguageServiceAndHost } from '../ts-ast-util/testing/testing-language-service'; -import { createScriptSourceHelper } from '../ts-ast-util/script-source-helper'; - -function createExtractor(files: { fileName: string; content: string }[]) { - const { languageService, languageServiceHost } = createTestingLanguageServiceAndHost({ files }); - const extractor = new Extractor({ - removeDuplicatedFragments: true, - scriptSourceHelper: createScriptSourceHelper({ languageService, languageServiceHost }), - debug: () => {}, - }); - return extractor; -} +import { createTesintExtractor } from './testing/testing-extractor'; describe(MarkdownReporter, () => { it('should convert from manifest to markdown content', () => { - const extractor = createExtractor([ + const extractor = createTesintExtractor([ { fileName: '/prj-root/src/main.ts', content: ` @@ -50,7 +38,7 @@ describe(MarkdownReporter, () => { }); it('should convert from manifest to markdown content with ignoreFragments: false', () => { - const extractor = createExtractor([ + const extractor = createTesintExtractor([ { fileName: '/prj-root/src/main.ts', content: ` diff --git a/src/analyzer/testing/testing-extractor.ts b/src/analyzer/testing/testing-extractor.ts index cd37f44b1..a2f825317 100644 --- a/src/analyzer/testing/testing-extractor.ts +++ b/src/analyzer/testing/testing-extractor.ts @@ -1,4 +1,5 @@ import { Extractor } from '../extractor'; +import { FragmentRegistry } from '../../gql-ast-util'; import { createTestingLanguageServiceAndHost } from '../../ts-ast-util/testing/testing-language-service'; import { createScriptSourceHelper } from '../../ts-ast-util'; @@ -9,7 +10,15 @@ export function createTesintExtractor( const { languageService, languageServiceHost } = createTestingLanguageServiceAndHost({ files }); const extractor = new Extractor({ removeDuplicatedFragments, - scriptSourceHelper: createScriptSourceHelper({ languageService, languageServiceHost }), + scriptSourceHelper: createScriptSourceHelper( + { + languageService, + languageServiceHost, + project: { getProjectName: () => '' }, + }, + { exclude: [] }, + ), + fragmentRegistry: new FragmentRegistry(), debug: () => {}, }); return extractor; diff --git a/src/analyzer/type-generator.test.ts b/src/analyzer/type-generator.test.ts index 293a1ad14..7113c2341 100644 --- a/src/analyzer/type-generator.test.ts +++ b/src/analyzer/type-generator.test.ts @@ -35,9 +35,11 @@ describe(TypeGenerator, () => { }, ], }); - const result = extractor.extract(['main.ts']) as ExtractSucceededResult[]; + const { + fileEntries: [fileEntry], + } = extractor.extract(['main.ts']) as { fileEntries: ExtractSucceededResult[] }; const { addon, context } = generator.createAddon({ - extractedResult: result[0], + fileEntry, schema, outputSource: createOutputSource({ outputFileName: 'my-query.ts' }), }); diff --git a/src/analyzer/type-generator.ts b/src/analyzer/type-generator.ts index bf10ff45b..ed1c3f414 100644 --- a/src/analyzer/type-generator.ts +++ b/src/analyzer/type-generator.ts @@ -1,6 +1,6 @@ import path from 'path'; import ts from 'typescript'; -import { GraphQLSchema } from 'graphql/type'; +import { GraphQLSchema } from 'graphql'; import { TsGqlError, ErrorWithLocation } from '../errors'; import { mergeAddons, TypeGenVisitor, TypeGenError, TypeGenAddonFactory, TypeGenVisitorAddonContext } from '../typegen'; @@ -35,20 +35,20 @@ export class TypeGenerator { createAddon({ schema, - extractedResult, + fileEntry, outputSource, }: { schema: GraphQLSchema; - extractedResult: ExtractSucceededResult; + fileEntry: ExtractSucceededResult; outputSource: OutputSource; }) { const context: TypeGenVisitorAddonContext = { schema, source: outputSource, extractedInfo: { - fileName: extractedResult.fileName, - tsTemplateNode: extractedResult.templateNode, - tsSourceFile: extractedResult.templateNode.getSourceFile(), + fileName: fileEntry.fileName, + tsTemplateNode: fileEntry.templateNode, + tsSourceFile: fileEntry.templateNode.getSourceFile(), }, }; const addons = this._addonFactories.map(factory => factory(context)); @@ -56,22 +56,23 @@ export class TypeGenerator { } generateTypes({ files, schema }: { files: string[]; schema: GraphQLSchema }) { - const extractedResults = this._extractor.extract(files, this._tag); - const extractedErrors = this._extractor.pickupErrors(extractedResults); + const extractedResult = this._extractor.extract(files, this._tag); + const extractedErrors = this._extractor.pickupErrors(extractedResult); if (extractedErrors.length) { this._debug(`Found ${extractedErrors.length} extraction errors.`); } const typegenErrors: TsGqlError[] = []; const visitor = new TypeGenVisitor({ schema }); const outputSourceFiles: { fileName: string; content: string }[] = []; - extractedResults.forEach(extractedResult => { - if (extractedResult.documentNode) { - const { type, fragmentName, operationName } = this._extractor.getDominantDefinition(extractedResult); + extractedResult.fileEntries.forEach(fileEntry => { + if (fileEntry.documentNode) { + const { inflatedDocumentNode, isDefinedExternal } = this._extractor.inflateDocument(fileEntry, extractedResult); + const { type, fragmentName, operationName } = this._extractor.getDominantDefinition(fileEntry); if (type === 'complex') { - const fileName = extractedResult.fileName; - const content = extractedResult.templateNode.getSourceFile().getFullText(); - const start = extractedResult.templateNode.getStart(); - const end = extractedResult.templateNode.getEnd(); + const fileName = fileEntry.fileName; + const content = fileEntry.templateNode.getSourceFile().getFullText(); + const start = fileEntry.templateNode.getStart(); + const end = fileEntry.templateNode.getEnd(); const errorContent = { fileName, content, start, end }; const error = new ErrorWithLocation('This document node has complex operations.', errorContent); typegenErrors.push(error); @@ -80,30 +81,31 @@ export class TypeGenerator { const operationOrFragmentName = type === 'fragment' ? fragmentName : operationName; if (!operationOrFragmentName) return; const outputFileName = path.resolve( - path.dirname(extractedResult.fileName), + path.dirname(fileEntry.fileName), '__generated__', dasherize(operationOrFragmentName) + '.ts', ); try { const outputSource = createOutputSource({ outputFileName }); - const { addon } = this.createAddon({ schema, outputSource, extractedResult }); - const outputSourceFile = visitor.visit(extractedResult.documentNode, { outputSource, addon }); + const { addon } = this.createAddon({ schema, outputSource, fileEntry }); + const outputSourceFile = visitor.visit(inflatedDocumentNode, { outputSource, addon }); const content = this._printer.printFile(outputSourceFile); outputSourceFiles.push({ fileName: outputFileName, content }); this._debug( `Create type source file '${path.relative(this._prjRootPath, outputFileName)}' from '${path.relative( this._prjRootPath, - extractedResult.fileName, + fileEntry.fileName, )}'.`, ); } catch (error) { if (error instanceof TypeGenError) { - const sourcePosition = extractedResult.resolevedTemplateInfo.getSourcePosition(error.node.loc!.start); + if (isDefinedExternal(error.node)) return; + const sourcePosition = fileEntry.resolevedTemplateInfo.getSourcePosition(error.node.loc!.start); if (sourcePosition.isInOtherExpression) return; - const fileName = extractedResult.fileName; - const content = extractedResult.templateNode.getSourceFile().getFullText(); + const fileName = fileEntry.fileName; + const content = fileEntry.templateNode.getSourceFile().getFullText(); const start = sourcePosition.pos; - const end = extractedResult.resolevedTemplateInfo.getSourcePosition(error.node.loc!.end).pos; + const end = fileEntry.resolevedTemplateInfo.getSourcePosition(error.node.loc!.end).pos; const errorContent = { fileName, content, start, end }; const translatedError = new ErrorWithLocation(error.message, errorContent); typegenErrors.push(translatedError); diff --git a/src/analyzer/validator.ts b/src/analyzer/validator.ts index 83ba3f0e9..224b4ed77 100644 --- a/src/analyzer/validator.ts +++ b/src/analyzer/validator.ts @@ -1,34 +1,85 @@ import { GraphQLSchema } from 'graphql'; -import { getDiagnostics } from 'graphql-language-service'; +import { getDiagnostics, getFragmentDependenciesForAST } from 'graphql-language-service'; +import { ErrorWithLocation, ERROR_CODES } from '../errors'; +import { ComputePosition } from '../ts-ast-util'; +import { getFragmentsInDocument, getFragmentNamesInDocument, cloneFragmentMap } from '../gql-ast-util'; import { ExtractResult } from './extractor'; -import { ErrorWithLocation } from '../errors'; +import { OutOfRangeError } from '../string-util'; -export function validate(extractedResults: ExtractResult[], schema: GraphQLSchema) { +function calcEndPositionSafely( + startPositionOfSource: number, + getSourcePosition: ComputePosition, + innerPosition: number, +) { + let endPositionOfSource: number = 0; + try { + endPositionOfSource = getSourcePosition(innerPosition).pos; + } catch (error) { + endPositionOfSource = startPositionOfSource + 1; + } + return endPositionOfSource; +} + +export function validate({ fileEntries: extractedResults, globalFragments }: ExtractResult, schema: GraphQLSchema) { const errors: ErrorWithLocation[] = []; extractedResults.forEach(r => { if (!r.resolevedTemplateInfo) return; const { combinedText, getSourcePosition, convertInnerLocation2InnerPosition } = r.resolevedTemplateInfo; - const diagnostics = getDiagnostics(combinedText, schema); + const fragmentNamesInText = getFragmentNamesInDocument(r.documentNode); + errors.push( + ...getFragmentsInDocument(r.documentNode) + .map(fragmentDef => [fragmentDef, getSourcePosition(fragmentDef.name.loc!.start)] as const) + .filter( + ([fragmentDef, { isInOtherExpression }]) => + !isInOtherExpression && globalFragments.duplicatedDefinitions.has(fragmentDef.name.value), + ) + .map( + ([fragmentDef, { pos: startPositionOfSource }]) => + new ErrorWithLocation(ERROR_CODES.duplicatedFragmentDefinitions.message, { + fileName: r.fileName, + severity: 'Error', + content: r.templateNode.getSourceFile().getText(), + start: startPositionOfSource, + end: calcEndPositionSafely(startPositionOfSource, getSourcePosition, fragmentDef.name.loc!.end), + }), + ), + ); + const externalFragments = r.documentNode + ? getFragmentDependenciesForAST( + r.documentNode, + cloneFragmentMap(globalFragments.definitionMap, fragmentNamesInText), + ) + : []; + const diagnostics = getDiagnostics(combinedText, schema, undefined, undefined, externalFragments); diagnostics.forEach(diagnositc => { - const { pos: startPositionOfSource, isInOtherExpression } = getSourcePosition( - convertInnerLocation2InnerPosition(diagnositc.range.start), - ); - if (isInOtherExpression) return; - let endPositionOfSource: number = 0; try { - endPositionOfSource = getSourcePosition(convertInnerLocation2InnerPosition(diagnositc.range.end)).pos; - } catch (error) { - endPositionOfSource = startPositionOfSource + 1; + const { pos: startPositionOfSource, isInOtherExpression } = getSourcePosition( + convertInnerLocation2InnerPosition(diagnositc.range.start, true), + ); + if (isInOtherExpression) return; + const endPositionOfSource = calcEndPositionSafely( + startPositionOfSource, + getSourcePosition, + convertInnerLocation2InnerPosition(diagnositc.range.end), + ); + errors.push( + new ErrorWithLocation(diagnositc.message, { + fileName: r.fileName, + severity: diagnositc.severity === 2 ? 'Warn' : 'Error', + content: r.templateNode.getSourceFile().getText(), + start: startPositionOfSource, + end: endPositionOfSource, + }), + ); + } catch (e) { + if (e instanceof OutOfRangeError) { + // Note: + // We can not convertInnerLocation2InnerPosition if semantics diagnostics are located in externalFragments. + // In other words, there is no error in the original sanitized template text, so nothing to do. + return; + } + throw e; } - errors.push( - new ErrorWithLocation(diagnositc.message, { - fileName: r.fileName, - severity: diagnositc.severity === 2 ? 'Warn' : 'Error', - content: r.templateNode.getSourceFile().getText(), - start: startPositionOfSource, - end: endPositionOfSource, - }), - ); }); }); return errors; diff --git a/src/cache/index.ts b/src/cache/index.ts new file mode 100644 index 000000000..43558e57f --- /dev/null +++ b/src/cache/index.ts @@ -0,0 +1 @@ +export * from './lru-cache'; diff --git a/src/cache/lru-cache.test.ts b/src/cache/lru-cache.test.ts new file mode 100644 index 000000000..513b842e6 --- /dev/null +++ b/src/cache/lru-cache.test.ts @@ -0,0 +1,45 @@ +import { LRUCache } from './lru-cache'; + +describe(LRUCache, () => { + test('should return cached value', () => { + const cache = new LRUCache(1); + + cache.set('a', 'a'); + + expect(cache.get('a')).toBe('a'); + }); + + test('should release entry via delete', () => { + const cache = new LRUCache(1); + + cache.set('a', 'a'); + cache.delete('a'); + + expect(cache.get('a')).toBe(undefined); + }); + + it('should store entries whose size is specified length via maxLength', () => { + const cache = new LRUCache(2); + + cache.set('a', 'a'); + cache.set('b', 'b'); + cache.set('c', 'c'); + + expect(cache.has('a')).toBeFalsy(); + expect(cache.has('b')).toBeTruthy(); + expect(cache.has('c')).toBeTruthy(); + }); + + it('should hold entries last recently used', () => { + const cache = new LRUCache(2); + + cache.set('a', 'a'); + cache.set('b', 'b'); + cache.get('a'); + cache.set('c', 'c'); + + expect(cache.has('a')).toBeTruthy(); + expect(cache.has('b')).toBeFalsy(); + expect(cache.has('c')).toBeTruthy(); + }); +}); diff --git a/src/cache/lru-cache.ts b/src/cache/lru-cache.ts new file mode 100644 index 000000000..b1b8a60a3 --- /dev/null +++ b/src/cache/lru-cache.ts @@ -0,0 +1,29 @@ +export class LRUCache { + private _cacheMap = new Map(); + + constructor(private _maxSize: number = 100) {} + + set(key: TKey, value: TValue) { + this._cacheMap.set(key, value); + if (this._cacheMap.size > this._maxSize) { + const lru = this._cacheMap.keys().next(); + this._cacheMap.delete(lru.value); + } + } + + get(key: TKey) { + const result = this._cacheMap.get(key); + if (!result) return; + this._cacheMap.delete(key); + this._cacheMap.set(key, result); + return result; + } + + has(key: TKey) { + return this._cacheMap.has(key); + } + + delete(key: TKey) { + this._cacheMap.delete(key); + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts index 8c180c1a1..1c3bf204e 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -44,6 +44,10 @@ export const ERROR_CODES = { graphqlLangServiceError: { code: 51001, }, + duplicatedFragmentDefinitions: { + code: 51002, + message: 'All fragments must have an unique name.', + }, templateIsTooComplex: { code: 51010, message: 'This operation or fragment has too complex interpolation to analyze.', diff --git a/src/gql-ast-util/__snapshots__/index.test.ts.snap b/src/gql-ast-util/__snapshots__/utility-functions.test.ts.snap similarity index 100% rename from src/gql-ast-util/__snapshots__/index.test.ts.snap rename to src/gql-ast-util/__snapshots__/utility-functions.test.ts.snap diff --git a/src/gql-ast-util/fragment-registry.test.ts b/src/gql-ast-util/fragment-registry.test.ts new file mode 100644 index 000000000..ea467ec0e --- /dev/null +++ b/src/gql-ast-util/fragment-registry.test.ts @@ -0,0 +1,495 @@ +import { FragmentRegistry, DefinitionFileStore } from './fragment-registry'; + +describe(DefinitionFileStore, () => { + class TestingStore extends DefinitionFileStore { + update(fileName: string, texts: string[]) { + super.updateDocuments( + fileName, + texts.map(text => ({ text, extra: null })), + ); + } + } + const createTestingStore = () => { + const store = new TestingStore({ + enabledDebugAssertInvaliant: true, + // ["A, B"] => [{ name: "A", node: "contentOfA" }, { name: "B", node: "contentOfB" }] + parse: text => + text.split(',').map(f => ({ name: f.trim().split(':')[0], node: `contentOf${f.trim().toUpperCase()}` })), + }); + return store; + }; + + describe(DefinitionFileStore.prototype.updateDocuments, () => { + it('shouild not change nothing given empty array', () => { + const store = createTestingStore(); + store.update('main.ts', []); + expect(store.getStoreVersion()).toBe(0); + }); + + describe('first regstration', () => { + describe('when given single text', () => { + const store = createTestingStore(); + beforeEach(() => { + store.update('main.ts', ['A']); + }); + + test('correct version', () => { + expect(store.getStoreVersion()).toBe(1); + }); + + test('correct unique definition', () => { + expect([...store.getUniqueDefinitonMap().keys()]).toMatchObject(['A']); + }); + + it('correct duplicated definition', () => { + expect([...store.getDuplicatedDefinitonMap().keys()]).toEqual([]); + }); + }); + + describe('when given text which is parsed to duplicated definitions', () => { + let store: TestingStore; + beforeEach(() => { + store = createTestingStore(); + store.update('main.ts', ['A, A']); + }); + + test('correct version', () => { + expect(store.getStoreVersion()).toBe(1); + }); + + test('correct unique definition', () => { + expect([...store.getUniqueDefinitonMap().keys()]).toEqual([]); + }); + + test('correct duplicated definition', () => { + expect([...store.getDuplicatedDefinitonMap().keys()]).toMatchObject(['A']); + }); + }); + + describe('when given duplicated texts', () => { + let store: TestingStore; + beforeEach(() => { + store = createTestingStore(); + store.update('main.ts', ['A', 'A']); + }); + + test('correct version', () => { + expect(store.getStoreVersion()).toBe(1); + }); + + test('correct unique definition', () => { + expect([...store.getUniqueDefinitonMap().keys()]).toEqual([]); + }); + + test('correct duplicated definition', () => { + expect([...store.getDuplicatedDefinitonMap().keys()]).toMatchObject(['A']); + }); + }); + + describe('when given complex duplicated texts', () => { + let store: TestingStore; + beforeEach(() => { + store = createTestingStore(); + store.update('main.ts', ['A:0', 'A:1,A:2']); + }); + + test('correct version', () => { + expect(store.getStoreVersion()).toBe(1); + }); + + test('correct unique definition', () => { + expect([...store.getUniqueDefinitonMap().keys()]).toEqual([]); + }); + + test('correct duplicated definition', () => { + expect([...store.getDuplicatedDefinitonMap().keys()]).toMatchObject(['A']); + }); + }); + + describe('when given complex texts', () => { + let store: TestingStore; + beforeEach(() => { + store = createTestingStore(); + store.update('main.ts', ['A:0', 'A:1,A:2', 'B']); + }); + + test('correct version', () => { + expect(store.getStoreVersion()).toBe(1); + }); + + test('correct unique definition', () => { + expect([...store.getUniqueDefinitonMap().keys()]).toMatchObject(['B']); + }); + + test('correct duplicated definition', () => { + expect([...store.getDuplicatedDefinitonMap().keys()]).toMatchObject(['A']); + }); + }); + }); + + describe('updating', () => { + describe.each` + docs | updated | appeared | disappeared | unique | duplicated + ${[]} | ${[]} | ${[]} | ${['A']} | ${[]} | ${[]} + ${['A:0']} | ${[]} | ${[]} | ${[]} | ${['A']} | ${[]} + ${['A:1']} | ${['A']} | ${[]} | ${[]} | ${['A']} | ${[]} + ${['A:1', 'A:2']} | ${[]} | ${[]} | ${['A']} | ${[]} | ${['A']} + ${['B:0']} | ${[]} | ${['B']} | ${['A']} | ${['B']} | ${[]} + `('file1: ["A:0"] -> file1: $docs', ({ docs, ...expected }) => { + let store: TestingStore; + beforeEach(() => { + store = createTestingStore(); + store.update('main.ts', ['A:0']); + store.update('main.ts', docs); + }); + + test('correct history', () => { + expect([...store.getDetailedAffectedDefinitions(1)[0].updated.values()]).toEqual(expected.updated); + expect([...store.getDetailedAffectedDefinitions(1)[0].appeared.values()]).toEqual(expected.appeared); + expect([...store.getDetailedAffectedDefinitions(1)[0].disappeared.values()]).toEqual(expected.disappeared); + }); + + test('correct unique definition', () => { + expect([...store.getUniqueDefinitonMap().keys()]).toEqual(expected.unique); + }); + + test('correct duplicated definition', () => { + expect([...store.getDuplicatedDefinitonMap().keys()]).toEqual(expected.duplicated); + }); + }); + + describe.each` + docs | updated | appeared | disappeared | unique | duplicated + ${[]} | ${[]} | ${[]} | ${[]} | ${[]} | ${[]} + ${['A:0']} | ${[]} | ${['A']} | ${[]} | ${['A']} | ${[]} + ${['A:1']} | ${[]} | ${['A']} | ${[]} | ${['A']} | ${[]} + ${['A:1', 'A:2']} | ${[]} | ${[]} | ${[]} | ${[]} | ${['A']} + ${['B:0']} | ${[]} | ${['B']} | ${[]} | ${['B']} | ${[]} + `('file1: ["A:0", "A:1"] -> file1: $docs', ({ docs, ...expected }) => { + let store: TestingStore; + beforeEach(() => { + store = createTestingStore(); + store.update('main.ts', ['A:0', 'A:1']); + store.update('main.ts', docs); + }); + + test('correct history', () => { + expect([...store.getDetailedAffectedDefinitions(1)[0].updated.values()]).toEqual(expected.updated); + expect([...store.getDetailedAffectedDefinitions(1)[0].appeared.values()]).toEqual(expected.appeared); + expect([...store.getDetailedAffectedDefinitions(1)[0].disappeared.values()]).toEqual(expected.disappeared); + }); + + test('correct unique definition', () => { + expect([...store.getUniqueDefinitonMap().keys()]).toEqual(expected.unique); + }); + + test('correct duplicated definition', () => { + expect([...store.getDuplicatedDefinitonMap().keys()]).toEqual(expected.duplicated); + }); + }); + + describe.each` + docs | updated | appeared | disappeared | unique | duplicated + ${['A:0', 'A:1']} | ${[]} | ${[]} | ${[]} | ${[]} | ${['A']} + `('file1: ["A:0", "A:1", "A:2"] -> file1: $docs', ({ docs, ...expected }) => { + let store: TestingStore; + beforeEach(() => { + store = createTestingStore(); + store.update('main.ts', ['A:0', 'A:1', 'A:2']); + store.update('main.ts', docs); + }); + + test('correct history', () => { + expect([...store.getDetailedAffectedDefinitions(1)[0].updated.values()]).toEqual(expected.updated); + expect([...store.getDetailedAffectedDefinitions(1)[0].appeared.values()]).toEqual(expected.appeared); + expect([...store.getDetailedAffectedDefinitions(1)[0].disappeared.values()]).toEqual(expected.disappeared); + }); + + test('correct unique definition', () => { + expect([...store.getUniqueDefinitonMap().keys()]).toEqual(expected.unique); + }); + + test('correct duplicated definition', () => { + expect([...store.getDuplicatedDefinitonMap().keys()]).toEqual(expected.duplicated); + }); + }); + + describe('file1: ["A:0"] -> file1: ["A:0", "A:0"] -> file1: ["B:0", "A:0"]', () => { + let store: TestingStore; + beforeEach(() => { + store = createTestingStore(); + store.update('main.ts', ['A:0']); + store.update('main.ts', ['A:0', 'A:0']); + store.update('main.ts', ['B:0', 'A:0']); + }); + + test('correct history', () => { + expect([...store.getDetailedAffectedDefinitions(2)[0].updated.values()]).toEqual([]); + expect([...store.getDetailedAffectedDefinitions(2)[0].appeared.values()]).toEqual(['B', 'A']); + expect([...store.getDetailedAffectedDefinitions(2)[0].disappeared.values()]).toEqual([]); + }); + + test('correct unique definition', () => { + expect([...store.getUniqueDefinitonMap().keys()]).toEqual(['B', 'A']); + }); + + test('correct duplicated definition', () => { + expect([...store.getDuplicatedDefinitonMap().keys()]).toEqual([]); + }); + }); + + describe.each` + file1Docs | file2Docs | affected | unique | duplicated + ${['A:0', 'B:0']} | ${['A:1']} | ${['B', 'A']} | ${['B']} | ${['A']} + ${['B:0']} | ${['A:0']} | ${['B', 'A']} | ${['B', 'A']} | ${[]} + `( + 'file1: ["A:0"] -> file2: [] -> file1: $file1Docs -> file2: $file2Docs', + ({ file1Docs, file2Docs, ...expected }) => { + let store: TestingStore; + beforeEach(() => { + store = createTestingStore(); + store.update('main.ts', ['A:0']); + store.update('sub.ts', []); + store.update('main.ts', file1Docs); + store.update('sub.ts', file2Docs); + }); + + test('correct history', () => { + expect([...store.getSummarizedAffectedDefinitions(1).values()]).toEqual(expected.affected); + }); + + test('correct unique definition', () => { + expect([...store.getUniqueDefinitonMap().keys()]).toEqual(expected.unique); + }); + + test('correct duplicated definition', () => { + expect([...store.getDuplicatedDefinitonMap().keys()]).toEqual(expected.duplicated); + }); + }, + ); + }); + }); +}); + +describe(FragmentRegistry, () => { + describe(FragmentRegistry.prototype.getExternalFragments, () => { + it('should return empty array when target document can not be parsed', () => { + const registry = new FragmentRegistry(); + registry.registerDocuments('fragments.ts', '0', [ + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, + ]); + registry.registerDocuments('main.ts', '0', [{ sourcePosition: 0, text: 'fragment X on Query {' }]); + + expect(registry.getExternalFragments('fragment X on Query {', 'main.ts', 0)).toEqual([]); + }); + + it('should return dependent fragment definitions', () => { + const registry = new FragmentRegistry(); + registry.registerDocuments('fragments.ts', '0', [ + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, + { + sourcePosition: 0, + text: ` + fragment FragmentX on Query { + __typename + } + `, + }, + ]); + registry.registerDocuments('main.ts', '0', [ + { sourcePosition: 0, text: 'fragment FragmentB on Query { ...FragmentA }' }, + ]); + + const actual = registry.getExternalFragments('fragment FragmentB on Query { ...FragmentA }', 'main.ts', 0); + expect(actual.length).toBe(1); + expect(actual[0].name.value).toBe('FragmentA'); + }); + + describe('cache', () => { + it('should not use cached value when dependent fragment changes', () => { + const logger = jest.fn(); + const registry = new FragmentRegistry({ logger }); + registry.registerDocuments('fragments.ts', '0', [ + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, + ]); + + registry.registerDocuments('main.ts', '0', [ + { sourcePosition: 0, text: 'fragment FragmentX on Query { ...FragmentA }' }, + ]); + registry.getExternalFragments('fragment FragmentX on Query { ...FragmentA }', 'main.ts', 0); + expect(logger).toBeCalledTimes(0); + + registry.registerDocuments('fragments.ts', '1', [ + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + id + } + `, + }, + ]); + + const actual = registry.getExternalFragments('fragment FragmentX on Query { ...FragmentA }', 'main.ts', 0); + + expect(logger).toBeCalledTimes(0); + expect(actual.length).toBe(1); + expect(actual[0].name.value).toBe('FragmentA'); + }); + + it('should not use cached value when FragmentSpread set in target documentString changes', () => { + const logger = jest.fn(); + const registry = new FragmentRegistry({ logger }); + registry.registerDocuments('fragments.ts', '0', [ + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, + { + sourcePosition: 0, + text: ` + fragment FragmentB on Query { + __typename + } + `, + }, + ]); + registry.registerDocuments('main.ts', '0', [ + { sourcePosition: 0, text: 'fragment FragmentX on Query { ...FragmentA }' }, + ]); + + registry.getExternalFragments('fragment FragmentX on Query { ...FragmentA }', 'main.ts', 0); + expect(logger).toBeCalledTimes(0); + + registry.registerDocuments('main.ts', '1', [ + { sourcePosition: 0, text: 'fragment FragmentX on Query { ...FragmentA, ...FragmentB }' }, + ]); + + const actual = registry.getExternalFragments( + 'fragment FragmentX on Query { ...FragmentA, ...FragmentB }', + 'main.ts', + 0, + ); + + expect(logger).toBeCalledTimes(0); + expect(actual.length).toBe(2); + }); + + it('should not use cached value when FragmentDefinition set in target documentString changes', () => { + const logger = jest.fn(); + const registry = new FragmentRegistry({ logger }); + registry.registerDocuments('fragments.ts', '0', [ + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, + ]); + registry.registerDocuments('main.ts', '0', [ + { sourcePosition: 0, text: 'fragment FragmentX on Query { ...FragmentA }' }, + ]); + + registry.getExternalFragments('fragment FragmentX on Query { ...FragmentA }', 'main.ts', 0); + expect(logger).toBeCalledTimes(0); + + registry.registerDocuments('main.ts', '1', [ + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + fragment FragmentX on Query { ...FragmentA, ...FragmentB } + `, + }, + ]); + + const actual = registry.getExternalFragments( + ` + fragment FragmentA on Query { + __typename + } + fragment FragmentX on Query { ...FragmentA, ...FragmentB } + `, + 'main.ts', + 0, + ); + + expect(logger).toBeCalledTimes(0); + expect(actual.length).toBe(0); + }); + + it('should use cached value', () => { + const logger = jest.fn(); + const registry = new FragmentRegistry({ logger }); + registry.registerDocuments('fragments.ts', '0', [ + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, + { + sourcePosition: 0, + text: ` + fragment FragmentB on Query { + __typename + } + `, + }, + ]); + registry.registerDocuments('main.ts', '0', [ + { sourcePosition: 0, text: 'fragment FragmentX on Query { ...FragmentA }' }, + ]); + + registry.getExternalFragments('fragment FragmentX on Query { ...FragmentA }', 'main.ts', 0); + expect(logger).toBeCalledTimes(0); + + registry.registerDocuments('main.ts', '1', [ + { sourcePosition: 0, text: 'fragment FragmentX on Query { ...FragmentA, __typename }' }, + ]); + + const actual = registry.getExternalFragments( + 'fragment FragmentX on Query { ...FragmentA, __typename }', + 'main.ts', + 0, + ); + + expect(logger).toBeCalledTimes(1); + expect(actual.length).toBe(1); + expect(actual[0].name.value).toBe('FragmentA'); + }); + }); + }); +}); diff --git a/src/gql-ast-util/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts new file mode 100644 index 000000000..a9b4940a6 --- /dev/null +++ b/src/gql-ast-util/fragment-registry.ts @@ -0,0 +1,384 @@ +import { parse, type DocumentNode, FragmentDefinitionNode, visit } from 'graphql'; +import { getFragmentDependenciesForAST } from 'graphql-language-service'; +import { LRUCache } from '../cache'; +import { getFragmentsInDocument, getFragmentNamesInDocument } from './utility-functions'; + +function union(...sets: Set[]) { + return new Set(sets.map(s => [...s.values()]).flat()); +} + +function intersect(a: Set, b: Set) { + const [x, y] = a.size < b.size ? [new Set(a), new Set(b)] : [new Set(b), new Set(a)]; + const ret = new Set(); + for (const v of x.values()) { + if (y.has(v)) { + ret.add(v); + x.delete(v); + y.delete(v); + } + } + if (a.size < b.size) { + return [ret, x, y] as const; + } else { + return [ret, y, x] as const; + } +} + +function compare(a: Set, b: Set) { + if (a.size !== b.size) return false; + let result = true; + for (const key of a.keys()) { + result &&= b.has(key); + } + return result; +} + +function groupBy(arr: S[], keyPropName: TKey) { + const map = new Map(); + for (const item of arr) { + const hit = map.get(item[keyPropName]); + if (hit) { + hit.push(item); + } else { + map.set(item[keyPropName], [item]); + } + } + return map; +} + +type DefinitionAST = unknown; + +type DefinitionParser = (documentString: string) => { name: string; node: T }[]; + +type DefinitionEntry = { + fileName: string; + extra: TExtra; + documentString: string; + definitionName: string; + node: T; +}; + +type AffectedDefinitonNameHistoryEntry = { + updated: Set; + appeared: Set; + disappeared: Set; +}; + +export class DefinitionFileStore { + private _storeVersion = 0; + private _affectedDefinitonNameHistories: AffectedDefinitonNameHistoryEntry[] = []; + + private _mapByFileName = new Map[]>(); + + private _uniqueRecordMap = new Map>(); + private _duplicatedRecordMap = new Map[]>(); + + private _parse: DefinitionParser; + private _enabledDebugAssertInvaliant: boolean; + + constructor({ + parse, + enabledDebugAssertInvaliant = false, + }: { + parse: DefinitionParser; + enabledDebugAssertInvaliant?: boolean; + }) { + this._parse = parse; + this._enabledDebugAssertInvaliant = enabledDebugAssertInvaliant; + } + + assertInvariant() { + if (intersect(new Set(this._uniqueRecordMap.keys()), new Set(this._duplicatedRecordMap.keys()))[0].size > 0) { + throw new Error('There should be no intersected keys between _duplicatedRecordMap and _uniqueRecordMap'); + } + if ( + !compare( + new Set([...this._mapByFileName.values()].flat()), + union(new Set(this._uniqueRecordMap.values()), new Set([...this._duplicatedRecordMap.values()].flat())), + ) + ) { + throw new Error('All values in _mapByFileName should appear _uniqueRecordMap or _duplicatedRecordMap'); + } + } + + getStoreVersion() { + return this._storeVersion; + } + + getDetailedAffectedDefinitions(from: number) { + return this._affectedDefinitonNameHistories.slice(from); + } + + getSummarizedAffectedDefinitions(from: number) { + return union( + ...this._affectedDefinitonNameHistories.slice(from).flatMap(x => [x.appeared, x.disappeared, x.updated]), + ); + } + + getUniqueDefinitonMap() { + return this._uniqueRecordMap; + } + + getDuplicatedDefinitonMap() { + return this._duplicatedRecordMap; + } + + updateDocuments(fileName: string, documents: { text: string; extra: TExtra }[]) { + this._assert(); + + if (!documents.length && !this._mapByFileName.has(fileName)) return; + + const currentValues = this._mapByFileName.get(fileName) ?? []; + const currentValueMapByText = groupBy(currentValues, 'documentString'); + const valuesNotChanged: typeof currentValues = []; + const valuesToBeAdded: typeof currentValues = []; + + for (const doc of documents) { + const alreadyParsedItems = currentValueMapByText.get(doc.text); + if (alreadyParsedItems && alreadyParsedItems.length === 1) { + alreadyParsedItems[0].extra = doc.extra; + valuesNotChanged.push(alreadyParsedItems[0]); + currentValueMapByText.delete(doc.text); + continue; + } + + for (const parsed of this._parse(doc.text)) { + valuesToBeAdded.push({ + fileName, + extra: doc.extra, + documentString: doc.text, + definitionName: parsed.name, + node: parsed.node, + }); + } + } + + const valuesToBeRemoved = [...currentValueMapByText.values()].flat(); + + const { namesRemoved, namesFromDuplicatedToUnique } = this._removeValueFromRecordMaps(...valuesToBeRemoved); + const { namesAcquiredAsUnique, namesToDuplicatedFromUnique } = this._addValueToRecordMaps(...valuesToBeAdded); + const [namesUpdated, namesFullyRemoved, namesFullyAppeared] = intersect(namesRemoved, namesAcquiredAsUnique); + const [, namesRecovered, namesHidden] = intersect(namesFromDuplicatedToUnique, namesToDuplicatedFromUnique); + const namesAppeared = union(namesFullyAppeared, namesRecovered); + const namesDisappeared = union(namesFullyRemoved, namesHidden); + this._affectedDefinitonNameHistories[this._storeVersion] = { + updated: namesUpdated, + appeared: namesAppeared, + disappeared: namesDisappeared, + }; + this._mapByFileName.set(fileName, [...valuesNotChanged, ...valuesToBeAdded]); + this._storeVersion++; + + this._assert(); + } + + private _assert() { + if (this._enabledDebugAssertInvaliant) { + try { + this.assertInvariant(); + } catch (e) { + /* eslint-disable no-console */ + console.log('_mapByFileName:', this._mapByFileName); + console.log('_uniqueRecordMap:', this._uniqueRecordMap); + console.log('_duplicatedRecordMap:', this._duplicatedRecordMap); + /* eslint-enable no-console */ + throw e; + } + } + } + + private _addValueToRecordMaps(...values: DefinitionEntry[]) { + const namesAcquiredAsUnique = new Set(); + const namesToDuplicatedFromUnique = new Set(); + for (const v of values) { + const name = v.definitionName; + + const alreadyStoredValuesAsDuplicated = this._duplicatedRecordMap.get(name); + if (alreadyStoredValuesAsDuplicated) { + alreadyStoredValuesAsDuplicated?.push(v); + continue; + } + + const alreadyStoredValueAsUnique = this._uniqueRecordMap.get(name); + if (alreadyStoredValueAsUnique) { + this._uniqueRecordMap.delete(name); + this._duplicatedRecordMap.set(name, [alreadyStoredValueAsUnique, v]); + namesToDuplicatedFromUnique.add(name); + } else { + this._uniqueRecordMap.set(name, v); + namesAcquiredAsUnique.add(name); + } + } + + for (const name of namesAcquiredAsUnique.values()) { + if (!this._uniqueRecordMap.has(name)) { + namesAcquiredAsUnique.delete(name); + } + } + + return { namesAcquiredAsUnique, namesToDuplicatedFromUnique }; + } + + private _removeValueFromRecordMaps(...values: DefinitionEntry[]) { + const namesFromDuplicatedToUnique = new Set(); + const namesRemoved = new Set(); + for (const v of values) { + const name = v.definitionName; + + const alreadyStoredValueAsUnique = this._uniqueRecordMap.get(name); + if (alreadyStoredValueAsUnique) { + this._uniqueRecordMap.delete(name); + if (!namesFromDuplicatedToUnique.has(name)) { + namesRemoved.add(name); + } + continue; + } + + const alreadyStoredValuesAsDuplicated = this._duplicatedRecordMap.get(name); + if (alreadyStoredValuesAsDuplicated && alreadyStoredValuesAsDuplicated.length == 2) { + const [a, b] = alreadyStoredValuesAsDuplicated; + const itemToBeKept = a === v ? b : a; + this._uniqueRecordMap.set(name, itemToBeKept); + this._duplicatedRecordMap.delete(name); + namesFromDuplicatedToUnique.add(name); + } else if (alreadyStoredValuesAsDuplicated && alreadyStoredValuesAsDuplicated.length > 2) { + const s = new Set(alreadyStoredValuesAsDuplicated); + s.delete(v); + this._duplicatedRecordMap.set(name, [...s.values()]); + } + } + + for (const name of namesFromDuplicatedToUnique.values()) { + if (!this._uniqueRecordMap.has(name)) { + namesFromDuplicatedToUnique.delete(name); + } + } + + return { + namesFromDuplicatedToUnique, + namesRemoved, + }; + } +} + +type FileName = string; +type FileVersion = string; + +type ExternalFragmentsCacheEntry = { + storeVersion: number; + internalFragmentNames: string[]; + referencedFragmentNames: string[]; + externalFragments: FragmentDefinitionNode[]; +}; + +type FragmentRegistryCreateOptions = { + logger: (msg: string) => void; +}; + +export class FragmentRegistry { + private _fileVersionMap = new Map(); + + private _store = new DefinitionFileStore({ + parse: documentStr => { + try { + const documentNode = parse(documentStr); + return getFragmentsInDocument(documentNode).map(node => ({ node, name: node.name.value })); + } catch { + return []; + } + }, + }); + + private _externalFragmentsCache = new LRUCache(100); + private _logger: (msg: string) => void; + + constructor(options: FragmentRegistryCreateOptions = { logger: () => null }) { + this._logger = options.logger; + } + + getFileCurrentVersion(fileName: string) { + return this._fileVersionMap.get(fileName); + } + + getFragmentDefinitions(): FragmentDefinitionNode[] { + return this.getFragmentDefinitionsWithMap().definitions; + } + + getFragmentDefinitionsWithMap(): { definitions: FragmentDefinitionNode[]; map: Map } { + const map = new Map([...this._store.getUniqueDefinitonMap().entries()].map(([k, { node }]) => [k, node])); + const definitions = [...map.values()]; + return { + map, + definitions, + }; + } + + getDuplicaterdFragmentDefinitions() { + return new Set(this._store.getDuplicatedDefinitonMap().keys()); + } + + getExternalFragments(documentStr: string, fileName: string, sourcePosition: number): FragmentDefinitionNode[] { + let documentNode: DocumentNode | undefined = undefined; + try { + documentNode = parse(documentStr); + } catch { + // Nothing to do + } + if (!documentNode) return []; + const names = getFragmentNamesInDocument(documentNode); + const cacheKey = `${fileName}:${sourcePosition}`; + const cachedValue = this._externalFragmentsCache.get(cacheKey); + if (cachedValue) { + if (compare(new Set(cachedValue.internalFragmentNames), new Set(names))) { + const changed = this._store.getSummarizedAffectedDefinitions(cachedValue.storeVersion); + let affectd = false; + const referencedFragmentNames = new Set(); + visit(documentNode, { + FragmentSpread: node => { + affectd ||= changed.has(node.name.value); + referencedFragmentNames.add(node.name.value); + }, + }); + if (!affectd && compare(referencedFragmentNames, new Set(cachedValue.referencedFragmentNames))) { + this._logger('getExternalFragments: use cached value'); + return cachedValue.externalFragments; + } + } + } + const map = new Map( + [...this._store.getUniqueDefinitonMap().entries()] + .filter(([name]) => !names.includes(name)) + .map(([k, v]) => [k, v.node]), + ); + const externalFragments = getFragmentDependenciesForAST(documentNode, map); + const referencedFragmentNames: string[] = []; + visit(documentNode, { + FragmentSpread: node => { + referencedFragmentNames.push(node.name.value); + }, + }); + this._externalFragmentsCache.set(cacheKey, { + storeVersion: this._store.getStoreVersion(), + internalFragmentNames: names, + externalFragments, + referencedFragmentNames, + }); + return externalFragments; + } + + registerDocuments( + fileName: string, + version: string, + documentStrings: { text: string; sourcePosition: number }[], + ): void { + this._fileVersionMap.set(fileName, version); + this._store.updateDocuments( + fileName, + documentStrings.map(({ text, sourcePosition }) => ({ text, extra: { sourcePosition } })), + ); + } + + removeDocument(fileName: string): void { + this._store.updateDocuments(fileName, []); + } +} diff --git a/src/gql-ast-util/index.ts b/src/gql-ast-util/index.ts index 8f66a81e4..2511683f0 100644 --- a/src/gql-ast-util/index.ts +++ b/src/gql-ast-util/index.ts @@ -1,24 +1,2 @@ -import { DocumentNode, FragmentDefinitionNode } from 'graphql'; - -export function detectDuplicatedFragments(documentNode: DocumentNode) { - const fragments: FragmentDefinitionNode[] = []; - const duplicatedFragments: FragmentDefinitionNode[] = []; - documentNode.definitions.forEach(def => { - if (def.kind === 'FragmentDefinition') { - if (fragments.some(f => f.name.value === def.name.value)) { - duplicatedFragments.push(def); - } else { - fragments.push(def); - } - } - }); - return duplicatedFragments - .map(def => { - return { - name: def.name.value, - start: def.loc!.start, - end: def.loc!.end, - }; - }) - .sort((a, b) => b.start - a.start); -} +export * from './fragment-registry'; +export * from './utility-functions'; diff --git a/src/gql-ast-util/index.test.ts b/src/gql-ast-util/utility-functions.test.ts similarity index 94% rename from src/gql-ast-util/index.test.ts rename to src/gql-ast-util/utility-functions.test.ts index b22e4063f..4d3b049c3 100644 --- a/src/gql-ast-util/index.test.ts +++ b/src/gql-ast-util/utility-functions.test.ts @@ -1,5 +1,5 @@ -import { detectDuplicatedFragments } from './'; import { parse } from 'graphql'; +import { detectDuplicatedFragments } from './utility-functions'; describe(detectDuplicatedFragments, () => { it('should detect duplicated fragments info', () => { diff --git a/src/gql-ast-util/utility-functions.ts b/src/gql-ast-util/utility-functions.ts new file mode 100644 index 000000000..052278c22 --- /dev/null +++ b/src/gql-ast-util/utility-functions.ts @@ -0,0 +1,58 @@ +import { DocumentNode, FragmentDefinitionNode } from 'graphql'; + +export function getFragmentsInDocument(...documentNodes: (DocumentNode | undefined)[]) { + const fragmentDefs = new Map(); + for (const documentNode of documentNodes) { + if (!documentNode) return []; + for (const def of documentNode.definitions) { + if (def.kind === 'FragmentDefinition') { + fragmentDefs.set(def.name.value, def); + } + } + } + return [...fragmentDefs.values()]; +} + +export function getFragmentNamesInDocument(...documentNodes: (DocumentNode | undefined)[]) { + const nameSet = new Set(); + for (const documentNode of documentNodes) { + if (!documentNode) return []; + for (const def of documentNode.definitions) { + if (def.kind === 'FragmentDefinition') { + nameSet.add(def.name.value); + } + } + } + return [...nameSet]; +} + +export function cloneFragmentMap(from: Map, namesToBeExcluded: string[] = []) { + const map = new Map(from); + for (const name in namesToBeExcluded) { + map.delete(name); + } + return map; +} + +export function detectDuplicatedFragments(documentNode: DocumentNode) { + const fragments: FragmentDefinitionNode[] = []; + const duplicatedFragments: FragmentDefinitionNode[] = []; + documentNode.definitions.forEach(def => { + if (def.kind === 'FragmentDefinition') { + if (fragments.some(f => f.name.value === def.name.value)) { + duplicatedFragments.push(def); + } else { + fragments.push(def); + } + } + }); + return duplicatedFragments + .map(def => { + return { + name: def.name.value, + start: def.loc!.start, + end: def.loc!.end, + }; + }) + .sort((a, b) => b.start - a.start); +} diff --git a/src/graphql-language-service-adapter/get-completion-at-position.test.ts b/src/graphql-language-service-adapter/get-completion-at-position.test.ts index cc0dac757..52b8e6977 100644 --- a/src/graphql-language-service-adapter/get-completion-at-position.test.ts +++ b/src/graphql-language-service-adapter/get-completion-at-position.test.ts @@ -52,4 +52,15 @@ describe('getCompletionAtPosition', () => { expect(completionFn(17, undefined)!.entries).toBeTruthy(); expect(completionFn(17, undefined)!.entries.filter(e => e.name === 'hello').length).toBeTruthy(); // contains schema keyword; }); + + it('should return completion entries with external fragment definitions', () => { + const fixture = createFixture('input.ts', createSimpleSchema()); + const completionFn = fixture.adapter.getCompletionAtPosition.bind(fixture.adapter, delegateFn, 'input.ts'); + + fixture.addFragment('fragment FRAGMENT on Query { hello }'); + fixture.source = 'const a = `query { ...FR'; + expect(completionFn(23, undefined)!.entries).toBeTruthy(); + expect(completionFn(23, undefined)!.entries.length).not.toBe(0); + expect(completionFn(23, undefined)!.entries.filter(e => e.name === 'FRAGMENT').length).toBeTruthy(); // contains schema keyword; + }); }); diff --git a/src/graphql-language-service-adapter/get-completion-at-position.ts b/src/graphql-language-service-adapter/get-completion-at-position.ts index 0dd420b8c..543f3a4a7 100644 --- a/src/graphql-language-service-adapter/get-completion-at-position.ts +++ b/src/graphql-language-service-adapter/get-completion-at-position.ts @@ -30,6 +30,7 @@ export function getCompletionAtPosition( options: ts.GetCompletionsAtPositionOptions | undefined, formattingSettings?: ts.FormatCodeSettings | undefined, ) { + if (ctx.getScriptSourceHelper().isExcluded(fileName)) return delegate(fileName, position, options); const schema = ctx.getSchema(); if (!schema) return delegate(fileName, position, options); const node = ctx.findTemplateNode(fileName, position); @@ -49,7 +50,13 @@ export function getCompletionAtPosition( line: innerLocation.line, character: innerLocation.character, }); - const gqlCompletionItems = getAutocompleteSuggestions(schema, combinedText, positionForSeach); + const gqlCompletionItems = getAutocompleteSuggestions( + schema, + combinedText, + positionForSeach, + undefined, + ctx.getGlobalFragmentDefinitions(), + ); ctx.debug(JSON.stringify(gqlCompletionItems)); return translateCompletionItems(gqlCompletionItems); } diff --git a/src/graphql-language-service-adapter/get-quick-info-at-position.ts b/src/graphql-language-service-adapter/get-quick-info-at-position.ts index 1d98af511..1aec9f3fd 100644 --- a/src/graphql-language-service-adapter/get-quick-info-at-position.ts +++ b/src/graphql-language-service-adapter/get-quick-info-at-position.ts @@ -9,6 +9,7 @@ export function getQuickInfoAtPosition( fileName: string, position: number, ) { + if (ctx.getScriptSourceHelper().isExcluded(fileName)) return delegate(fileName, position); const schema = ctx.getSchema(); if (!schema) return delegate(fileName, position); const node = ctx.findTemplateNode(fileName, position); diff --git a/src/graphql-language-service-adapter/get-semantic-diagonistics.test.ts b/src/graphql-language-service-adapter/get-semantic-diagonistics.test.ts index 8b8e39d6b..917e1775d 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.test.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.test.ts @@ -54,7 +54,7 @@ describe('getSemanticDiagnostics', () => { expect(validateFn()).toEqual([]); }); - it('should return syntax error with empty template literal', () => { + it('should not report for empty template literal', () => { const fixture = craeteFixture('input.ts', createSimpleSchema()); const validateFn = fixture.adapter.getSemanticDiagnostics.bind(fixture.adapter, delegateFn, 'input.ts'); @@ -62,6 +62,17 @@ describe('getSemanticDiagnostics', () => { const query = \`\`; `; const actual = validateFn(); + expect(actual.length).toBe(0); + }); + + it('should return syntax error if template literal is not valid GraphQL syntax', () => { + const fixture = craeteFixture('input.ts', createSimpleSchema()); + const validateFn = fixture.adapter.getSemanticDiagnostics.bind(fixture.adapter, delegateFn, 'input.ts'); + + fixture.source = ` + const query = \`query {\`; + `; + const actual = validateFn(); expect(actual.length).toBe(1); expect((actual[0].messageText as string).match(/Syntax Error:/)).toBeTruthy(); }); @@ -136,6 +147,122 @@ describe('getSemanticDiagnostics', () => { expect(validateFn().length).toBe(1); }); + it('should exclude fragment definition itself as external fragments', () => { + const fixture = craeteFixture('input.ts', createSimpleSchema()); + const validateFn = fixture.adapter.getSemanticDiagnostics.bind(fixture.adapter, delegateFn, 'input.ts'); + + fixture.addFragment(` + fragment MyFragment on Query { + hello + } + `); + + fixture.source = ` + const fragment = \` + fragment MyFragment on Query { + hello + } + \`; + `; + const actual = validateFn(); + expect(actual.length).toBe(0); + }); + + it('should work with external fragments', () => { + const fixture = craeteFixture('input.ts', createSimpleSchema()); + const validateFn = fixture.adapter.getSemanticDiagnostics.bind(fixture.adapter, delegateFn, 'input.ts'); + + fixture.addFragment( + ` + fragment ExternalFragment1 on Query { + __typename + } + `, + 'fragment1.ts', + ); + + fixture.addFragment( + ` + fragment ExternalFragment2 on Query { + __typename + } + `, + 'fragment2.ts', + ); + + fixture.source = ` + const fragment = \` + fragment MyFragment on Query { + hello + ...ExternalFragment1 + } + \`; + `; + const actual = validateFn(); + expect(actual.length).toBe(0); + }); + + it('should not report error if non-dependent fragment has error', () => { + const fixture = craeteFixture('input.ts', createSimpleSchema()); + const validateFn = fixture.adapter.getSemanticDiagnostics.bind(fixture.adapter, delegateFn, 'input.ts'); + + fixture.addFragment( + ` + fragment DependentFragment on Query { + __typename + } + `, + 'fragment1.ts', + ); + + fixture.addFragment( + ` + fragment NonDependentFragment on Query { + __typename + notExistingFeild + } + `, + 'fragment2.ts', + ); + + fixture.source = ` + const fragment = \` + fragment MyFragment on Query { + hello + ...DependentFragment + } + \`; + `; + const actual = validateFn(); + expect(actual.length).toBe(0); + }); + + it('should not report error even if dependent fragment has error', () => { + const fixture = craeteFixture('input.ts', createSimpleSchema()); + const validateFn = fixture.adapter.getSemanticDiagnostics.bind(fixture.adapter, delegateFn, 'input.ts'); + + fixture.addFragment( + ` + fragment DependentFragment on Query { + __typename + notExistingFeild + } + `, + 'fragment1.ts', + ); + + fixture.source = ` + const fragment = \` + fragment MyFragment on Query { + hello + ...DependentFragment + } + \`; + `; + const actual = validateFn(); + expect(actual.length).toBe(0); + }); + it('should return "templateIsTooComplex" error when template node has too complex interpolation', () => { const fixture = craeteFixture('input.ts', createSimpleSchema()); const validateFn = fixture.adapter.getSemanticDiagnostics.bind(fixture.adapter, delegateFn, 'input.ts'); @@ -187,4 +314,44 @@ describe('getSemanticDiagnostics', () => { expect(actual[1].code).toBe(ERROR_CODES.errorInOtherInterpolation.code); expect(actual[1].start).toBe(frets.a1.pos); }); + + it('should return "duplicatedFragmentDefinitions" error when interpolated fragment has error', () => { + const fixture = craeteFixture('input.ts', createSimpleSchema()); + const validateFn = fixture.adapter.getSemanticDiagnostics.bind(fixture.adapter, delegateFn, 'input.ts'); + + fixture.addFragment( + ` + fragment MyFragment on Query { + __typename + } + `, + ); + fixture.addFragment( + ` + fragment MyFragment on Query { + __typename + } + `, + 'fragments.ts', + ); + + const frets: Frets = {}; + fixture.source = mark( + ` + const fragment = \` + fragment MyFragment on Query { + %%% ^ ^ %%% + %%% a1 a2 %%% + __typename + } + \`; + `, + frets, + ); + const actual = validateFn(); + expect(actual.length).toBe(1); + expect(actual[0].code).toBe(ERROR_CODES.duplicatedFragmentDefinitions.code); + expect(actual[0].start).toBe(frets.a1.pos); + expect(actual[0].length).toBe(frets.a2.pos - frets.a1.pos); + }); }); diff --git a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts index 681a79390..a5a2b20f9 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts @@ -1,8 +1,12 @@ import ts from 'typescript'; +import type { FragmentDefinitionNode } from 'graphql'; import { getDiagnostics, type Diagnostic } from 'graphql-language-service'; import { SchemaBuildErrorInfo } from '../schema-manager/schema-manager'; import { ERROR_CODES } from '../errors'; import { AnalysisContext, GetSemanticDiagnostics } from './types'; +import { getSanitizedTemplateText } from '../ts-ast-util'; +import { getFragmentsInDocument } from '../gql-ast-util'; +import { OutOfRangeError } from '../string-util'; function createSchemaErrorDiagnostic( errorInfo: SchemaBuildErrorInfo, @@ -22,6 +26,23 @@ function createSchemaErrorDiagnostic( return { category, code, messageText, file, start, length }; } +function createDuplicatedFragmentDefinitonsDiagnostic( + file: ts.SourceFile, + sourcePosition: number, + defNode: FragmentDefinitionNode, +): ts.Diagnostic { + const startInner = defNode.name.loc?.start ?? 0; + const length = defNode.name.loc?.end ? defNode.name.loc.end - startInner : 0; + return { + category: ts.DiagnosticCategory.Error, + file, + start: sourcePosition + startInner, + length, + messageText: ERROR_CODES.duplicatedFragmentDefinitions.message, + code: ERROR_CODES.duplicatedFragmentDefinitions.code, + }; +} + function createIsInOtherExpressionDiagnostic(file: ts.SourceFile, start: number, length: number) { const category = ts.DiagnosticCategory.Error; const code = ERROR_CODES.errorInOtherInterpolation.code; @@ -38,6 +59,7 @@ function translateDiagnostic(d: Diagnostic, file: ts.SourceFile, start: number, export function getSemanticDiagnostics(ctx: AnalysisContext, delegate: GetSemanticDiagnostics, fileName: string) { const errors = delegate(fileName) || []; + if (ctx.getScriptSourceHelper().isExcluded(fileName)) return errors; const nodes = ctx.findTemplateNodes(fileName); const result = [...errors]; const [schema, schemaErrors] = ctx.getSchemaOrSchemaErrors(); @@ -52,11 +74,24 @@ export function getSemanticDiagnostics(ctx: AnalysisContext, delegate: GetSemant }); } else if (schema) { const diagnosticsAndResolvedInfoList = nodes.map(n => { + const { text, sourcePosition } = getSanitizedTemplateText(n); + result.push( + ...getFragmentsInDocument(ctx.getGraphQLDocumentNode(text)) + .filter(fragmentDef => ctx.getDuplicaterdFragmentDefinitions().has(fragmentDef.name.value)) + .map(fragmentDef => + createDuplicatedFragmentDefinitonsDiagnostic(n.getSourceFile(), sourcePosition, fragmentDef), + ), + ); const { resolvedInfo, resolveErrors } = ctx.resolveTemplateInfo(fileName, n); + const externalFragments = resolvedInfo + ? ctx.getExternalFragmentDefinitions(resolvedInfo.combinedText, fileName, resolvedInfo.getSourcePosition(0).pos) + : []; return { resolveErrors, resolvedTemplateInfo: resolvedInfo, - diagnostics: resolvedInfo ? getDiagnostics(resolvedInfo.combinedText, schema) : [], + diagnostics: resolvedInfo + ? getDiagnostics(resolvedInfo.combinedText, schema, undefined, undefined, externalFragments) + : [], }; }); diagnosticsAndResolvedInfoList.forEach((info, i) => { @@ -83,19 +118,29 @@ export function getSemanticDiagnostics(ctx: AnalysisContext, delegate: GetSemant diagnostics.forEach(d => { let length = 0; const file = node.getSourceFile(); - const { pos: startPositionOfSource, isInOtherExpression } = getSourcePosition( - convertInnerLocation2InnerPosition(d.range.start), - ); try { - const endPositionOfSource = getSourcePosition(convertInnerLocation2InnerPosition(d.range.end)).pos; - length = endPositionOfSource - startPositionOfSource - 1; - } catch (error) { - length = 0; - } - if (isInOtherExpression) { - result.push(createIsInOtherExpressionDiagnostic(file, startPositionOfSource, length)); - } else { - result.push(translateDiagnostic(d, file, startPositionOfSource, length)); + const { pos: startPositionOfSource, isInOtherExpression } = getSourcePosition( + convertInnerLocation2InnerPosition(d.range.start, true), + ); + try { + const endPositionOfSource = getSourcePosition(convertInnerLocation2InnerPosition(d.range.end, true)).pos; + length = endPositionOfSource - startPositionOfSource - 1; + } catch (error) { + length = 0; + } + if (isInOtherExpression) { + result.push(createIsInOtherExpressionDiagnostic(file, startPositionOfSource, length)); + } else { + result.push(translateDiagnostic(d, file, startPositionOfSource, length)); + } + } catch (e) { + if (e instanceof OutOfRangeError) { + // Note: + // We can not convertInnerLocation2InnerPosition if semantics diagnostics are located in externalFragments. + // In other words, there is no error in the original sanitized template text, so nothing to do. + return; + } + throw e; } }); }); diff --git a/src/graphql-language-service-adapter/graphql-language-service-adapter.ts b/src/graphql-language-service-adapter/graphql-language-service-adapter.ts index d6bc299d6..64092a6ea 100644 --- a/src/graphql-language-service-adapter/graphql-language-service-adapter.ts +++ b/src/graphql-language-service-adapter/graphql-language-service-adapter.ts @@ -1,12 +1,13 @@ import ts from 'typescript'; -import { GraphQLSchema, parse } from 'graphql'; +import { GraphQLSchema, parse, type DocumentNode } from 'graphql'; import { isTagged, ScriptSourceHelper, TagCondition, isTemplateLiteralTypeNode } from '../ts-ast-util'; import { SchemaBuildErrorInfo } from '../schema-manager/schema-manager'; -import { detectDuplicatedFragments } from '../gql-ast-util'; +import { getFragmentNamesInDocument, detectDuplicatedFragments, type FragmentRegistry } from '../gql-ast-util'; import { AnalysisContext, GetCompletionAtPosition, GetSemanticDiagnostics, GetQuickInfoAtPosition } from './types'; import { getCompletionAtPosition } from './get-completion-at-position'; import { getSemanticDiagnostics } from './get-semantic-diagonistics'; import { getQuickInfoAtPosition } from './get-quick-info-at-position'; +import { LRUCache } from '../cache'; export interface GraphQLLanguageServiceAdapterCreateOptions { schema?: GraphQLSchema | null; @@ -14,6 +15,7 @@ export interface GraphQLLanguageServiceAdapterCreateOptions { logger?: (msg: string) => void; tag?: string; removeDuplicatedFragments: boolean; + fragmentRegistry: FragmentRegistry; } type Args = T extends (...args: infer A) => any ? A : never; @@ -24,6 +26,8 @@ export class GraphQLLanguageServiceAdapter { private readonly _tagCondition?: TagCondition; private readonly _removeDuplicatedFragments: boolean; private readonly _analysisContext: AnalysisContext; + private readonly _fragmentRegisry: FragmentRegistry; + private readonly _parsedDocumentCache = new LRUCache(500); constructor( private readonly _helper: ScriptSourceHelper, @@ -35,6 +39,7 @@ export class GraphQLLanguageServiceAdapter { if (opt.tag) this._tagCondition = opt.tag; this._removeDuplicatedFragments = opt.removeDuplicatedFragments; this._analysisContext = this._createAnalysisContext(); + this._fragmentRegisry = opt.fragmentRegistry; } getCompletionAtPosition(delegate: GetCompletionAtPosition, ...args: Args) { @@ -71,6 +76,11 @@ export class GraphQLLanguageServiceAdapter { return [this._schema, null]; } }, + getGraphQLDocumentNode: text => this._parse(text), + getGlobalFragmentDefinitions: () => this._fragmentRegisry.getFragmentDefinitions(), + getExternalFragmentDefinitions: (documentStr, fileName, sourcePosition) => + this._fragmentRegisry.getExternalFragments(documentStr, fileName, sourcePosition), + getDuplicaterdFragmentDefinitions: () => this._fragmentRegisry.getDuplicaterdFragmentDefinitions(), findTemplateNode: (fileName, position) => this._findTemplateNode(fileName, position), findTemplateNodes: fileName => this._findTemplateNodes(fileName), resolveTemplateInfo: (fileName, node) => this._resolveTemplateInfo(fileName, node), @@ -112,23 +122,35 @@ export class GraphQLLanguageServiceAdapter { return nodes; } + private _parse(text: string) { + const cached = this._parsedDocumentCache.get(text); + if (cached) return cached; + try { + const parsed = parse(text); + this._parsedDocumentCache.set(text, parsed); + return parsed; + } catch { + return undefined; + } + } + private _resolveTemplateInfo(fileName: string, node: ts.TemplateExpression | ts.NoSubstitutionTemplateLiteral) { const { resolvedInfo, resolveErrors } = this._helper.resolveTemplateLiteral(fileName, node); if (!resolvedInfo) return { resolveErrors }; - if (!this._removeDuplicatedFragments) return { resolveErrors, resolvedInfo }; - try { - const documentNode = parse(resolvedInfo.combinedText); - const duplicatedFragmentInfoList = detectDuplicatedFragments(documentNode); - const info = duplicatedFragmentInfoList.reduce((acc, fragmentInfo) => { - return this._helper.updateTemplateLiteralInfo(acc, fragmentInfo); - }, resolvedInfo); - return { resolvedInfo: info, resolveErrors }; - } catch (error) { + const documentNode = this._parse(resolvedInfo.combinedText); + if (!documentNode) { // Note: // `parse` throws GraphQL syntax error when combinedText is invalid for GraphQL syntax. // We don't need handle this error because getDiagnostics method in this class re-checks syntax with graphql-lang-service, return { resolvedInfo, resolveErrors }; } + const fragmentNames = getFragmentNamesInDocument(documentNode); + if (!this._removeDuplicatedFragments) return { resolveErrors, resolvedInfo, fragmentNames }; + const duplicatedFragmentInfoList = detectDuplicatedFragments(documentNode); + const info = duplicatedFragmentInfoList.reduce((acc, fragmentInfo) => { + return this._helper.updateTemplateLiteralInfo(acc, fragmentInfo); + }, resolvedInfo); + return { resolvedInfo: info, resolveErrors }; } private _logger: (msg: string) => void = () => {}; diff --git a/src/graphql-language-service-adapter/testing/adapter-fixture.ts b/src/graphql-language-service-adapter/testing/adapter-fixture.ts index be742ac02..8f2660403 100644 --- a/src/graphql-language-service-adapter/testing/adapter-fixture.ts +++ b/src/graphql-language-service-adapter/testing/adapter-fixture.ts @@ -1,6 +1,7 @@ import ts from 'typescript'; import { GraphQLSchema } from 'graphql'; import { createScriptSourceHelper } from '../../ts-ast-util'; +import { FragmentRegistry } from '../../gql-ast-util'; import { GraphQLLanguageServiceAdapter } from '../graphql-language-service-adapter'; import { createTestingLanguageServiceAndHost, @@ -12,6 +13,7 @@ export class AdapterFixture { readonly langService: ts.LanguageService; private readonly _sourceFileName: string; private readonly _langServiceHost: TestingLanguageServiceHost; + private readonly _fragmentRegistry: FragmentRegistry; constructor(sourceFileName: string, schema?: GraphQLSchema) { const { languageService, languageServiceHost } = createTestingLanguageServiceAndHost({ @@ -19,12 +21,17 @@ export class AdapterFixture { }); this._sourceFileName = sourceFileName; this._langServiceHost = languageServiceHost; + this._fragmentRegistry = new FragmentRegistry(); this.langService = languageService; this.adapter = new GraphQLLanguageServiceAdapter( - createScriptSourceHelper({ languageService, languageServiceHost }), + createScriptSourceHelper( + { languageService, languageServiceHost, project: { getProjectName: () => 'tsconfig.json' } }, + { exclude: [] }, + ), { schema: schema || null, removeDuplicatedFragments: true, + fragmentRegistry: this._fragmentRegistry, }, ); } @@ -36,4 +43,8 @@ export class AdapterFixture { set source(content: string) { this._langServiceHost.updateFile(this._sourceFileName, content); } + + addFragment(fragmentDefDoc: string, sourceFileName = this._sourceFileName) { + this._fragmentRegistry.registerDocuments(sourceFileName, 'v1', [{ sourcePosition: 0, text: fragmentDefDoc }]); + } } diff --git a/src/graphql-language-service-adapter/types.ts b/src/graphql-language-service-adapter/types.ts index b477ca495..7c1e54967 100644 --- a/src/graphql-language-service-adapter/types.ts +++ b/src/graphql-language-service-adapter/types.ts @@ -1,5 +1,5 @@ import ts from 'typescript'; -import { GraphQLSchema } from 'graphql'; +import { GraphQLSchema, type DocumentNode, type FragmentDefinitionNode } from 'graphql'; import { ScriptSourceHelper, ResolveResult } from '../ts-ast-util'; import { SchemaBuildErrorInfo } from '../schema-manager/schema-manager'; @@ -12,6 +12,14 @@ export interface AnalysisContext { getScriptSourceHelper(): ScriptSourceHelper; getSchema(): GraphQLSchema | null | undefined; getSchemaOrSchemaErrors(): [GraphQLSchema, null] | [null, SchemaBuildErrorInfo[]]; + getGlobalFragmentDefinitions(): FragmentDefinitionNode[]; + getExternalFragmentDefinitions( + documentStr: string, + fileName: string, + sourcePosition: number, + ): FragmentDefinitionNode[]; + getDuplicaterdFragmentDefinitions(): Set; + getGraphQLDocumentNode(text: string): DocumentNode | undefined; findTemplateNode( fileName: string, position: number, diff --git a/src/language-service-plugin/language-service-proxy-builder.ts b/src/language-service-plugin/language-service-proxy-builder.ts index 7c968c660..5deaa351f 100644 --- a/src/language-service-plugin/language-service-proxy-builder.ts +++ b/src/language-service-plugin/language-service-proxy-builder.ts @@ -2,13 +2,13 @@ import ts from 'typescript/lib/tsserverlibrary'; export type LanguageServiceMethodWrapper = ( delegate: ts.LanguageService[K], - info?: ts.server.PluginCreateInfo, + info?: { languageService: ts.LanguageService }, ) => ts.LanguageService[K]; export class LanguageServiceProxyBuilder { private _wrappers: any[] = []; - constructor(private _info: ts.server.PluginCreateInfo) {} + constructor(private _info: { languageService: ts.LanguageService }) {} wrap>(name: K, wrapper: Q) { this._wrappers.push({ name, wrapper }); diff --git a/src/language-service-plugin/plugin-module-factory.ts b/src/language-service-plugin/plugin-module-factory.ts index a7682b706..81fc3ac62 100644 --- a/src/language-service-plugin/plugin-module-factory.ts +++ b/src/language-service-plugin/plugin-module-factory.ts @@ -1,9 +1,17 @@ import ts from 'typescript/lib/tsserverlibrary'; import { TsGraphQLPluginConfigOptions } from '../types'; import { GraphQLLanguageServiceAdapter } from '../graphql-language-service-adapter'; -import { LanguageServiceProxyBuilder } from './language-service-proxy-builder'; import { SchemaManagerFactory, createSchemaManagerHostFromLSPluginInfo } from '../schema-manager'; -import { createScriptSourceHelper } from '../ts-ast-util'; +import { FragmentRegistry } from '../gql-ast-util'; +import { + createScriptSourceHelper, + registerDocumentChangeEvent, + hasTagged, + findAllNodes, + getSanitizedTemplateText, + createFileNameFilter, +} from '../ts-ast-util'; +import { LanguageServiceProxyBuilder } from './language-service-proxy-builder'; function create(info: ts.server.PluginCreateInfo): ts.LanguageService { const logger = (msg: string) => info.project.projectService.logger.info(`[ts-graphql-plugin] ${msg}`); @@ -13,11 +21,55 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { const config = info.config as TsGraphQLPluginConfigOptions; const tag = config.tag; const removeDuplicatedFragments = config.removeDuplicatedFragments === false ? false : true; - const adapter = new GraphQLLanguageServiceAdapter(createScriptSourceHelper(info), { + const enabledGlobalFragments = config.enabledGlobalFragments === true; + const isExcluded = createFileNameFilter({ specs: config.exclude, projectName: info.project.getProjectName() }); + + const fragmentRegistry = new FragmentRegistry({ logger }); + if (enabledGlobalFragments) { + const handleAcquireOrUpdate = (fileName: string, sourceFile: ts.SourceFile, version: string) => { + fragmentRegistry.registerDocuments( + fileName, + version, + findAllNodes(sourceFile, node => { + if (tag && ts.isTaggedTemplateExpression(node) && hasTagged(node, tag, sourceFile)) { + return node.template; + } else if (ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node)) { + return node; + } + }).map(node => getSanitizedTemplateText(node, sourceFile)), + ); + }; + + registerDocumentChangeEvent( + // Note: + // documentRegistry in ts.server.Project is annotated @internal + (info.project as any).documentRegistry as ts.DocumentRegistry, + { + onAcquire: (fileName, sourceFile, version) => { + if (!isExcluded(fileName) && info.languageServiceHost.getScriptFileNames().includes(fileName)) { + handleAcquireOrUpdate(fileName, sourceFile, version); + } + }, + onUpdate: (fileName, sourceFile, version) => { + if (!isExcluded(fileName) && info.languageServiceHost.getScriptFileNames().includes(fileName)) { + if (fragmentRegistry.getFileCurrentVersion(fileName) === version) return; + handleAcquireOrUpdate(fileName, sourceFile, version); + } + }, + onRelease: fileName => { + fragmentRegistry.removeDocument(fileName); + }, + }, + ); + } + + const scriptSourceHelper = createScriptSourceHelper(info, { exclude: config.exclude }); + const adapter = new GraphQLLanguageServiceAdapter(scriptSourceHelper, { schema, schemaErrors, logger, tag, + fragmentRegistry, removeDuplicatedFragments, }); diff --git a/src/string-util/position-converter.test.ts b/src/string-util/position-converter.test.ts index bc56cccfa..26d1c3683 100644 --- a/src/string-util/position-converter.test.ts +++ b/src/string-util/position-converter.test.ts @@ -18,6 +18,41 @@ def`; expect(pos2location(text, 4)).toStrictEqual({ line: 1, character: 0 }); }); }); + + describe('throwErrorIfOutOfRange', () => { + describe(pos2location, () => { + const text = 'abcd\nefg'; + it.each([-1, 8])('should throw an error if pos is out of range', pos => { + expect(() => pos2location(text, pos, true)).toThrowError(); + }); + + it.each([0, 7])('should not throw an error if pos is invalid', pos => { + expect(() => pos2location(text, pos, true)).not.toThrowError(); + }); + }); + + describe(location2pos, () => { + const text = 'abcd\nefg'; + it.each([ + { line: -1, character: 0 }, + { line: 0, character: -1 }, + { line: 0, character: 4 }, + { line: 1, character: -1 }, + { line: 1, character: 3 }, + { line: 2, character: 0 }, + ])('should throw an error if location is out of range', location => { + expect(() => location2pos(text, location, true)).toThrowError(); + }); + it.each([ + { line: 0, character: 0 }, + { line: 0, character: 3 }, + { line: 1, character: 0 }, + { line: 1, character: 2 }, + ])('should not throw an error if location is valid range', location => { + expect(() => location2pos(text, location, true)).not.toThrowError(); + }); + }); + }); }); describe('CRLF', () => { @@ -36,4 +71,39 @@ describe('CRLF', () => { expect(pos2location(text, 5)).toStrictEqual({ line: 1, character: 0 }); }); }); + + describe('throwErrorIfOutOfRange', () => { + describe(pos2location, () => { + const text = 'abcd\r\nefg'; + it.each([-1, 9])('should throw an error if pos is out of range', pos => { + expect(() => pos2location(text, pos, true)).toThrowError(); + }); + + it.each([0, 8])('should not throw an error if pos is invalid', pos => { + expect(() => pos2location(text, pos, true)).not.toThrowError(); + }); + }); + + describe(location2pos, () => { + const text = 'abcd\r\nefg'; + it.each([ + { line: -1, character: 0 }, + { line: 0, character: -1 }, + { line: 0, character: 4 }, + { line: 1, character: -1 }, + { line: 1, character: 3 }, + { line: 2, character: 0 }, + ])('should throw an error if location is out of range', location => { + expect(() => location2pos(text, location, true)).toThrowError(); + }); + it.each([ + { line: 0, character: 0 }, + { line: 0, character: 3 }, + { line: 1, character: 0 }, + { line: 1, character: 2 }, + ])('should not throw an error if location is valid range', location => { + expect(() => location2pos(text, location, true)).not.toThrowError(); + }); + }); + }); }); diff --git a/src/string-util/position-converter.ts b/src/string-util/position-converter.ts index e7ce3dffe..f63559e9b 100644 --- a/src/string-util/position-converter.ts +++ b/src/string-util/position-converter.ts @@ -1,4 +1,15 @@ -export function pos2location(content: string, pos: number) { +export class OutOfRangeError extends Error { + constructor() { + super('Out of range'); + } +} + +export function pos2location(content: string, pos: number, throwErrorIfOutOfRange = false) { + if (throwErrorIfOutOfRange) { + if (pos < 0 || content.length <= pos) { + throw new OutOfRangeError(); + } + } let l = 0, c = 0; for (let i = 0; i < content.length && i < pos; i++) { @@ -13,12 +24,24 @@ export function pos2location(content: string, pos: number) { return { line: l, character: c }; } -export function location2pos(content: string, location: { line: number; character: number }) { +export function location2pos( + content: string, + location: { line: number; character: number }, + throwErrorIfOutOfRange = false, +) { let il = 0, ic = 0; + if (throwErrorIfOutOfRange) { + if (location.line < 0 || location.character < 0) { + throw new OutOfRangeError(); + } + } for (let i = 0; i < content.length; i++) { const cc = content[i]; if (il === location.line) { + if (throwErrorIfOutOfRange && (cc === '\n' || (cc === '\r' && content[i + 1] === '\n'))) { + throw new OutOfRangeError(); + } if (ic === location.character) { return i; } @@ -30,5 +53,8 @@ export function location2pos(content: string, location: { line: number; characte ic++; } } + if (throwErrorIfOutOfRange) { + throw new OutOfRangeError(); + } return content.length; } diff --git a/src/transformer/transformer-host.ts b/src/transformer/transformer-host.ts index 523b749f5..a39232d3b 100644 --- a/src/transformer/transformer-host.ts +++ b/src/transformer/transformer-host.ts @@ -1,6 +1,6 @@ import ts from 'typescript'; import { DocumentNode } from 'graphql'; -import { Analyzer, AnalyzerFactory, ExtractResult } from '../analyzer'; +import { Analyzer, AnalyzerFactory, ExtractFileResult } from '../analyzer'; import { getTransformer, DocumentTransformer } from './transformer'; class DocumentNodeRegistory { @@ -18,7 +18,7 @@ class DocumentNodeRegistory { return positionMap.get(templateNode.getStart()); } - update(extractedResults: ExtractResult[]) { + update(extractedResults: ExtractFileResult[]) { extractedResults.forEach(result => { if (!result.documentNode) return; let positionMap = this._map.get(result.fileName); @@ -54,8 +54,8 @@ export class TransformerHost { } loadProject() { - const [, results] = this._analyzer.extract(); - this._documentNodeRegistory.update(results); + const [, { fileEntries }] = this._analyzer.extract(); + this._documentNodeRegistory.update(fileEntries); } updateFiles(fileNameList: string[]) { @@ -70,10 +70,10 @@ export class TransformerHost { // other-opened-file.ts: declare `query { ...X }` importing fragment from changed-file.ts // // In the above case, the transformed output of other-opened-file.ts should have GraphQL docuemnt corresponding to `fragment X on Query { fieldA } query { ...X }` - const [, results] = this._analyzer.extract([ + const [, { fileEntries }] = this._analyzer.extract([ ...new Set([...fileNameList, ...this._documentNodeRegistory.getFiles()]), ]); - this._documentNodeRegistory.update(results); + this._documentNodeRegistory.update(fileEntries); } getTransformer({ diff --git a/src/ts-ast-util/file-name-filter.test.ts b/src/ts-ast-util/file-name-filter.test.ts new file mode 100644 index 000000000..027870f1f --- /dev/null +++ b/src/ts-ast-util/file-name-filter.test.ts @@ -0,0 +1,40 @@ +import { createFileNameFilter } from './file-name-filter'; + +describe(createFileNameFilter, () => { + it('should return macher function for dirname', () => { + const match = createFileNameFilter({ + specs: ['__generated__'], + projectName: '/a/b/tsconfig.json', + }); + expect(match('/a/b/__generated__/x.ts')).toBeTruthy(); + expect(match('/a/b/x.ts')).toBeFalsy(); + }); + + it('should return macher function for dirname with trailing slash', () => { + const match = createFileNameFilter({ + specs: ['__generated__/'], + projectName: '/a/b/tsconfig.json', + }); + expect(match('/a/b/__generated__/x.ts')).toBeTruthy(); + expect(match('/a/b/x.ts')).toBeFalsy(); + }); + + it('should return macher function for filename', () => { + const match = createFileNameFilter({ + specs: ['__generated__/x.ts'], + projectName: '/a/b/tsconfig.json', + }); + expect(match('/a/b/__generated__/x.ts')).toBeTruthy(); + expect(match('/a/b/x.ts')).toBeFalsy(); + }); + + it('should work for win32', () => { + const match = createFileNameFilter({ + specs: ['__generated__'], + projectName: '\\a\\b\\tsconfig.json', + _forceWin32: true, + }); + expect(match('\\a\\b\\__generated__\\x.ts')).toBeTruthy(); + expect(match('\\a\\b\\x.ts')).toBeFalsy(); + }); +}); diff --git a/src/ts-ast-util/file-name-filter.ts b/src/ts-ast-util/file-name-filter.ts new file mode 100644 index 000000000..1443be50e --- /dev/null +++ b/src/ts-ast-util/file-name-filter.ts @@ -0,0 +1,22 @@ +import _path from 'node:path'; + +export function createFileNameFilter({ + specs, + projectName, + _forceWin32 = false, +}: { + specs: string[] | undefined; + projectName: string; + _forceWin32?: boolean; +}) { + const path = _forceWin32 ? _path.win32 : _path; + const excludeDocuments = (specs ?? []).map(str => (str[str.length - 1] === '/' ? str.slice(0, str.length - 1) : str)); + const projectRootDirName = path.dirname(projectName); + + const match = (fileName: string) => { + const normalized = path.relative(projectRootDirName, fileName).replace(/\\/g, '/'); + return excludeDocuments.some(spec => spec === normalized || normalized.startsWith(spec + '/')); + }; + + return match; +} diff --git a/src/ts-ast-util/index.ts b/src/ts-ast-util/index.ts index 301ddb253..66f2cc5d6 100644 --- a/src/ts-ast-util/index.ts +++ b/src/ts-ast-util/index.ts @@ -2,7 +2,9 @@ export * from './types'; export * from './ast-factory-alias'; export * from './utilily-functions'; export * from './template-expression-resolver'; +export * from './register-document-change-event'; export { ScriptHost } from './script-host'; export { createScriptSourceHelper } from './script-source-helper'; export { createOutputSource } from './output-source'; +export { createFileNameFilter } from './file-name-filter'; diff --git a/src/ts-ast-util/register-document-change-event.test.ts b/src/ts-ast-util/register-document-change-event.test.ts new file mode 100644 index 000000000..e98b77942 --- /dev/null +++ b/src/ts-ast-util/register-document-change-event.test.ts @@ -0,0 +1,126 @@ +import ts from 'typescript'; +import { registerDocumentChangeEvent } from './register-document-change-event'; + +class TestingScriptSnapshot implements ts.IScriptSnapshot { + constructor(private readonly text: string) {} + getText(start: number, end: number) { + return this.text.slice(start, end); + } + getLength() { + return 0; + } + getChangeRange() { + return undefined; + } +} + +describe(registerDocumentChangeEvent, () => { + it('should register listener called back on acquireDocument method', () => { + const docRegistry = ts.createDocumentRegistry(); + const cb = jest.fn(); + registerDocumentChangeEvent(docRegistry, { + onAcquire: cb, + onUpdate: jest.fn(), + onRelease: jest.fn(), + }); + docRegistry.acquireDocument('main.ts', ts.getDefaultCompilerOptions(), new TestingScriptSnapshot(''), 'version'); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.lastCall[0]).toBe('main.ts'); + expect(ts.isSourceFile(cb.mock.lastCall[1])).toBeTruthy(); + expect(cb.mock.lastCall[2]).toBe('version'); + }); + + it('should register listener called back on acquireDocumentWithKey method', () => { + const docRegistry = ts.createDocumentRegistry(); + const cb = jest.fn(); + registerDocumentChangeEvent(docRegistry, { + onAcquire: cb, + onUpdate: jest.fn(), + onRelease: jest.fn(), + }); + docRegistry.acquireDocumentWithKey( + 'main.ts', + 'main.ts' as any, + ts.getDefaultCompilerOptions(), + 'key' as any, + new TestingScriptSnapshot(''), + 'version', + ); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.lastCall[0]).toBe('main.ts'); + expect(ts.isSourceFile(cb.mock.lastCall[1])).toBeTruthy(); + expect(cb.mock.lastCall[2]).toBe('version'); + }); + + it('should register listener called back on updateDocument method', () => { + const docRegistry = ts.createDocumentRegistry(); + const cb = jest.fn(); + registerDocumentChangeEvent(docRegistry, { + onAcquire: jest.fn(), + onUpdate: cb, + onRelease: jest.fn(), + }); + docRegistry.updateDocument('main.ts', ts.getDefaultCompilerOptions(), new TestingScriptSnapshot(''), 'version'); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.lastCall[0]).toBe('main.ts'); + expect(ts.isSourceFile(cb.mock.lastCall[1])).toBeTruthy(); + expect(cb.mock.lastCall[2]).toBe('version'); + }); + + it('should register listener called back on updateDocumentWithKey method', () => { + const docRegistry = ts.createDocumentRegistry(); + const cb = jest.fn(); + registerDocumentChangeEvent(docRegistry, { + onAcquire: jest.fn(), + onUpdate: cb, + onRelease: jest.fn(), + }); + docRegistry.updateDocumentWithKey( + 'main.ts', + 'main.ts' as any, + ts.getDefaultCompilerOptions(), + 'key' as any, + new TestingScriptSnapshot(''), + 'version', + ); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.lastCall[0]).toBe('main.ts'); + expect(ts.isSourceFile(cb.mock.lastCall[1])).toBeTruthy(); + expect(cb.mock.lastCall[2]).toBe('version'); + }); + + it('should register listener called back on releaseDocument method', () => { + const docRegistry = ts.createDocumentRegistry(); + const cb = jest.fn(); + registerDocumentChangeEvent(docRegistry, { + onAcquire: jest.fn(), + onUpdate: jest.fn(), + onRelease: cb, + }); + docRegistry.acquireDocument('main.ts', ts.getDefaultCompilerOptions(), new TestingScriptSnapshot(''), 'version'); + docRegistry.releaseDocument('main.ts', ts.getDefaultCompilerOptions(), ts.ScriptKind.TS, undefined); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.lastCall[0]).toBe('main.ts'); + }); + + it('should register listener called back on releaseDocumentWithKey method', () => { + const docRegistry = ts.createDocumentRegistry(); + const cb = jest.fn(); + registerDocumentChangeEvent(docRegistry, { + onAcquire: jest.fn(), + onUpdate: jest.fn(), + onRelease: cb, + }); + docRegistry.acquireDocumentWithKey( + 'main.ts', + 'main.ts' as any, + ts.getDefaultCompilerOptions(), + 'key' as any, + new TestingScriptSnapshot(''), + 'version', + ); + docRegistry.releaseDocumentWithKey('main.ts' as any, 'key' as any, ts.ScriptKind.TS, undefined); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.lastCall[0]).toBe('main.ts'); + }); +}); diff --git a/src/ts-ast-util/register-document-change-event.ts b/src/ts-ast-util/register-document-change-event.ts new file mode 100644 index 000000000..08e780121 --- /dev/null +++ b/src/ts-ast-util/register-document-change-event.ts @@ -0,0 +1,64 @@ +import type ts from 'typescript'; + +type DocumentChangeEventListener = { + onAcquire: (fileName: string, sourceFile: ts.SourceFile, version: string) => void; + onUpdate?: (fileName: string, sourceFile: ts.SourceFile, version: string) => void; + onRelease?: (fileName: string) => void; +}; + +export function registerDocumentChangeEvent( + target: ts.DocumentRegistry, + documentChangeEventListener: DocumentChangeEventListener, +) { + target.acquireDocument = new Proxy(target.acquireDocument, { + apply: (delegate, thisArg, args: Parameters) => { + const [fileName, , , version] = args; + const sourceFile = delegate.apply(thisArg, args); + documentChangeEventListener.onAcquire(fileName, sourceFile, version); + return sourceFile; + }, + }); + + target.acquireDocumentWithKey = new Proxy(target.acquireDocumentWithKey, { + apply: (delegate, thisArg, args: Parameters) => { + const [fileName, , , , , version] = args; + const sourceFile = delegate.apply(thisArg, args); + documentChangeEventListener.onAcquire(fileName, sourceFile, version); + return sourceFile; + }, + }); + + target.updateDocument = new Proxy(target.updateDocument, { + apply: (delegate, thisArg, args: Parameters) => { + const [fileName, , , version] = args; + const sourceFile = delegate.apply(thisArg, args); + documentChangeEventListener.onUpdate?.(fileName, sourceFile, version); + return sourceFile; + }, + }); + + target.updateDocumentWithKey = new Proxy(target.updateDocumentWithKey, { + apply: (delegate, thisArg, args: Parameters) => { + const [fileName, , , , , version] = args; + const sourceFile = delegate.apply(thisArg, args); + documentChangeEventListener.onUpdate?.(fileName, sourceFile, version); + return sourceFile; + }, + }); + + target.releaseDocument = new Proxy(target.releaseDocument, { + apply: (delegate, thisArg, args: Parameters) => { + const [fileName] = args; + delegate.apply(thisArg, args); + documentChangeEventListener.onRelease?.(fileName); + }, + }); + + target.releaseDocumentWithKey = new Proxy(target.releaseDocumentWithKey, { + apply: (delegate, thisArg, args: Parameters) => { + const [fileName] = args; + delegate.apply(thisArg, args); + documentChangeEventListener.onRelease?.(fileName); + }, + }); +} diff --git a/src/ts-ast-util/script-source-helper.ts b/src/ts-ast-util/script-source-helper.ts index 9436cb7b6..0beb8916e 100644 --- a/src/ts-ast-util/script-source-helper.ts +++ b/src/ts-ast-util/script-source-helper.ts @@ -2,14 +2,24 @@ import ts from 'typescript'; import { ScriptSourceHelper } from './types'; import { findAllNodes, findNode } from './utilily-functions'; import { TemplateExpressionResolver } from './template-expression-resolver'; +import { createFileNameFilter } from './file-name-filter'; -export function createScriptSourceHelper({ - languageService, - languageServiceHost, -}: { - languageService: ts.LanguageService; - languageServiceHost: ts.LanguageServiceHost; -}): ScriptSourceHelper { +export function createScriptSourceHelper( + { + languageService, + languageServiceHost, + project, + }: { + languageService: ts.LanguageService; + languageServiceHost: ts.LanguageServiceHost; + project: { getProjectName: () => string }; + }, + { + exclude, + }: { + exclude: string[] | undefined; + }, +): ScriptSourceHelper { const getSourceFile = (fileName: string) => { const program = languageService.getProgram(); if (!program) { @@ -21,6 +31,7 @@ export function createScriptSourceHelper({ } return s; }; + const isExcluded = createFileNameFilter({ specs: exclude, projectName: project.getProjectName() }); const getNode = (fileName: string, position: number) => { return findNode(getSourceFile(fileName), position); }; @@ -32,12 +43,15 @@ export function createScriptSourceHelper({ const s = getSourceFile(fileName); return ts.getLineAndCharacterOfPosition(s, position); }; - const resolver = new TemplateExpressionResolver(languageService, (fileName: string) => - languageServiceHost.getScriptVersion(fileName), + const resolver = new TemplateExpressionResolver( + languageService, + (fileName: string) => languageServiceHost.getScriptVersion(fileName), + isExcluded, ); const resolveTemplateLiteral = resolver.resolve.bind(resolver); const updateTemplateLiteralInfo = resolver.update.bind(resolver); return { + isExcluded, getNode, getAllNodes, getLineAndChar, diff --git a/src/ts-ast-util/template-expression-resolver.test.ts b/src/ts-ast-util/template-expression-resolver.test.ts index 4d61c5701..729f26601 100644 --- a/src/ts-ast-util/template-expression-resolver.test.ts +++ b/src/ts-ast-util/template-expression-resolver.test.ts @@ -18,7 +18,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const [node] = findAllNodes(source, node => ts.isNoSubstitutionTemplateLiteral(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', node as ts.NoSubstitutionTemplateLiteral).resolvedInfo; if (!actual) return fail(); expect(actual.combinedText).toBe(''); @@ -47,7 +51,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const [node] = findAllNodes(source, node => ts.isNoSubstitutionTemplateLiteral(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', node as ts.NoSubstitutionTemplateLiteral).resolvedInfo; if (!actual) return fail(); @@ -93,7 +101,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const [node] = findAllNodes(source, node => ts.isNoSubstitutionTemplateLiteral(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', node as ts.NoSubstitutionTemplateLiteral).resolvedInfo; if (!actual) return fail(); @@ -148,7 +160,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const nodes = findAllNodes(source, node => ts.isTaggedTemplateExpression(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', nodes[1] as ts.TemplateExpression).resolvedInfo; if (!actual) return fail(); @@ -224,7 +240,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const nodes = findAllNodes(source, node => ts.isTaggedTemplateExpression(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', nodes[1] as ts.TemplateExpression).resolvedInfo; if (!actual) return fail(); @@ -296,7 +316,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const nodes = findAllNodes(source, node => ts.isTaggedTemplateExpression(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', nodes[1] as ts.TemplateExpression); if (actual.resolvedInfo) return fail(); expect(actual.resolveErrors).toStrictEqual([ @@ -322,7 +346,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const [node] = findAllNodes(source, node => ts.isNoSubstitutionTemplateLiteral(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', node as ts.NoSubstitutionTemplateLiteral).resolvedInfo; expect(actual?.combinedText).toBe('query { }'); }); @@ -339,7 +367,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const [node] = findAllNodes(source, node => ts.isTemplateExpression(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', node as ts.TemplateExpression).resolvedInfo; expect(actual?.combinedText).toMatchSnapshot(); }); @@ -366,7 +398,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const [node] = findAllNodes(source, node => ts.isTemplateExpression(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', node as ts.TemplateExpression).resolvedInfo; expect(actual?.combinedText).toMatchSnapshot(); }); @@ -394,7 +430,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const [node] = findAllNodes(source, node => ts.isTemplateExpression(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', node as ts.TemplateExpression).resolvedInfo; expect(actual?.combinedText).toMatchSnapshot(); }); @@ -422,7 +462,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const [node] = findAllNodes(source, node => ts.isTemplateExpression(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', node as ts.TemplateExpression).resolvedInfo; expect(actual?.combinedText).toMatchSnapshot(); }); @@ -452,7 +496,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const [node] = findAllNodes(source, node => ts.isTemplateExpression(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', node as ts.TemplateExpression).resolvedInfo; expect(actual?.combinedText).toMatchSnapshot(); }); @@ -480,7 +528,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const [node] = findAllNodes(source, node => ts.isTemplateExpression(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', node as ts.TemplateExpression).resolvedInfo; expect(actual?.combinedText).toMatchSnapshot(); }); @@ -513,7 +565,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const [node] = findAllNodes(source, node => ts.isTemplateExpression(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const actual = resolver.resolve('main.ts', node as ts.TemplateExpression).resolvedInfo; expect(actual?.combinedText).toMatchSnapshot(); }); @@ -548,7 +604,11 @@ describe(TemplateExpressionResolver.prototype.resolve, () => { const [node] = findAllNodes(langService.getProgram()!.getSourceFile('main.ts')!, node => ts.isTemplateExpression(node), ); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const firstResult = resolver.resolve('main.ts', node as ts.TemplateExpression); expect(resolver._resultCache.has(node)).toBeTruthy(); expect(resolver.resolve('main.ts', node as ts.TemplateExpression).resolvedInfo).toBe(firstResult.resolvedInfo); @@ -579,7 +639,11 @@ describe(TemplateExpressionResolver.prototype.update, () => { const source = langService.getProgram()!.getSourceFile('main.ts'); if (!source) return fail(); const nodes = findAllNodes(source, node => ts.isNoSubstitutionTemplateLiteral(node)); - const resolver = new TemplateExpressionResolver(langService, () => ''); + const resolver = new TemplateExpressionResolver( + langService, + () => '', + () => false, + ); const originalInfo = resolver.resolve('main.ts', nodes[0] as ts.NoSubstitutionTemplateLiteral).resolvedInfo; if (!originalInfo) return fail(); const actual = resolver.update( diff --git a/src/ts-ast-util/template-expression-resolver.ts b/src/ts-ast-util/template-expression-resolver.ts index 1a6945443..87b448814 100644 --- a/src/ts-ast-util/template-expression-resolver.ts +++ b/src/ts-ast-util/template-expression-resolver.ts @@ -135,6 +135,7 @@ export class TemplateExpressionResolver { constructor( private readonly _langService: ts.LanguageService, private readonly _getFileVersion: (fileName: string) => string, + private readonly _isExcluded: (fileName: string) => boolean, ) {} resolve( @@ -338,6 +339,7 @@ export class TemplateExpressionResolver { const defs = this._langService.getDefinitionAtPosition(currentFileName, currentNode.getStart()); if (!defs || !defs[0]) return { dependencies }; const def = defs[0]; + if (this._isExcluded(def.fileName)) return { dependencies }; const src = this._langService.getProgram()!.getSourceFile(def.fileName); if (!src) return { dependencies }; const found = findNode(src, def.textSpan.start); diff --git a/src/ts-ast-util/types.ts b/src/ts-ast-util/types.ts index 1b8224ff2..fd3692464 100644 --- a/src/ts-ast-util/types.ts +++ b/src/ts-ast-util/types.ts @@ -132,8 +132,14 @@ export interface ResolvedTemplateInfo { combinedText: string; getInnerPosition: ComputePosition; getSourcePosition: ComputePosition; - convertInnerPosition2InnerLocation: (pos: number) => { line: number; character: number }; - convertInnerLocation2InnerPosition: (location: { line: number; character: number }) => number; + convertInnerPosition2InnerLocation: ( + pos: number, + throwErrorIfOutOfRange?: boolean, + ) => { line: number; character: number }; + convertInnerLocation2InnerPosition: ( + location: { line: number; character: number }, + throwErrorIfOutOfRange?: boolean, + ) => number; } export interface ResolveErrorInfo { @@ -151,6 +157,7 @@ export interface ScriptSourceHelper { getAllNodes: (fileName: string, condition: (n: ts.Node) => boolean) => ts.Node[]; getNode: (fileName: string, position: number) => ts.Node | undefined; getLineAndChar: (fileName: string, position: number) => ts.LineAndCharacter; + isExcluded: (fileName: string) => boolean; resolveTemplateLiteral: ( fileName: string, node: ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression, diff --git a/src/ts-ast-util/utilily-functions.test.ts b/src/ts-ast-util/utilily-functions.test.ts index 14ffad70b..893014f11 100644 --- a/src/ts-ast-util/utilily-functions.test.ts +++ b/src/ts-ast-util/utilily-functions.test.ts @@ -2,6 +2,7 @@ import ts from 'typescript'; import { findAllNodes, findNode, + getSanitizedTemplateText, isTagged, isImportDeclarationWithCondition, mergeImportDeclarationsWithSameModules, @@ -13,7 +14,7 @@ describe(isTagged, () => { it('should return true when the tag condition is matched', () => { // prettier-ignore const text = 'function myTag(...args: any[]) { return "" }' + '\n' - + 'const x = myTag`query { }`'; + + 'const x = myTag`query { }`'; const s = ts.createSourceFile('input.ts', text, ts.ScriptTarget.Latest, true); const node = findNode(s, text.length - 3) as ts.Node; expect(isTagged(node, 'myTag')).toBeTruthy(); @@ -22,7 +23,7 @@ describe(isTagged, () => { it('should return true when the tag condition is not matched', () => { // prettier-ignore const text = 'function myTag(...args: any[]) { return "" }' + '\n' - + 'const x = myTag`query { }`'; + + 'const x = myTag`query { }`'; const s = ts.createSourceFile('input.ts', text, ts.ScriptTarget.Latest, true); const node = findNode(s, text.length - 3) as ts.Node; expect(isTagged(node, 'MyTag')).toBeFalsy(); @@ -33,7 +34,7 @@ describe(findAllNodes, () => { it('should return nodes which match given condition', () => { // prettier-ignore const text = 'const a = `AAA`;' + '\n' - + 'const b = `BBB`;'; + + 'const b = `BBB`;'; const s = ts.createSourceFile('input.ts', text, ts.ScriptTarget.Latest, true); const actual = findAllNodes(s, node => node.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral); expect(actual.length).toBe(2); @@ -53,6 +54,48 @@ describe(findAllNodes, () => { }); }); +describe(getSanitizedTemplateText, () => { + describe('when node was parsed with parent', () => { + it('should return rawText for NoSubstitutionTemplateLiteral node', () => { + const text = 'const a = `abc`;'; + const s = ts.createSourceFile('input.ts', text, ts.ScriptTarget.Latest, true); + const [found] = findAllNodes(s, node => ts.isNoSubstitutionTemplateLiteral(node) && node); + const actual = getSanitizedTemplateText(found); + expect(actual.text).toBe('abc'); + expect(actual.sourcePosition).toBe(text.indexOf('abc')); + }); + + it('should replace variable placeholders in TemplateExpression node', () => { + const text = 'const a = `abc${hoge}def`;'; + const s = ts.createSourceFile('input.ts', text, ts.ScriptTarget.Latest, true); + const [found] = findAllNodes(s, node => ts.isTemplateExpression(node) && node); + const actual = getSanitizedTemplateText(found); + expect(actual.text).toBe('abc def'); + expect(actual.sourcePosition).toBe(text.indexOf('abc')); + }); + }); + + describe('when node was parsed without parent', () => { + it('should return rawText for NoSubstitutionTemplateLiteral node', () => { + const text = 'const a = `abc`;'; + const s = ts.createSourceFile('input.ts', text, ts.ScriptTarget.Latest, false); + const [found] = findAllNodes(s, node => ts.isNoSubstitutionTemplateLiteral(node) && node); + const actual = getSanitizedTemplateText(found, s); + expect(actual.text).toBe('abc'); + expect(actual.sourcePosition).toBe(text.indexOf('abc')); + }); + + it('should replace variable placeholders in TemplateExpression node', () => { + const text = 'const a = `abc${hoge}def`;'; + const s = ts.createSourceFile('input.ts', text, ts.ScriptTarget.Latest, false); + const [found] = findAllNodes(s, node => ts.isTemplateExpression(node) && node); + const actual = getSanitizedTemplateText(found, s); + expect(actual.text).toBe('abc def'); + expect(actual.sourcePosition).toBe(text.indexOf('abc')); + }); + }); +}); + describe(isImportDeclarationWithCondition, () => { it('should return false when node is not importDeclaration', () => { const text = `export default hoge;`; diff --git a/src/ts-ast-util/utilily-functions.ts b/src/ts-ast-util/utilily-functions.ts index 57840ac4f..34826b65b 100644 --- a/src/ts-ast-util/utilily-functions.ts +++ b/src/ts-ast-util/utilily-functions.ts @@ -66,16 +66,35 @@ export function findAllNodes( return result as S[]; } -export function hasTagged(node: ts.Node | undefined, condition: TagCondition) { +export function hasTagged(node: ts.Node | undefined, condition: TagCondition, source?: ts.SourceFile) { if (!node) return; if (!ts.isTaggedTemplateExpression(node)) return false; const tagNode = node; - return tagNode.tag.getText() === condition; + return tagNode.tag.getText(source) === condition; } -export function isTagged(node: ts.Node | undefined, condition: TagCondition) { +export function isTagged(node: ts.Node | undefined, condition: TagCondition, source?: ts.SourceFile) { if (!node) return false; - return hasTagged(node.parent, condition); + return hasTagged(node.parent, condition, source); +} + +export function getSanitizedTemplateText( + node: ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression, + source?: ts.SourceFile, +) { + const sourcePosition = node.getStart(source) + 1; + if (ts.isNoSubstitutionTemplateLiteral(node)) { + return { text: node.text ?? '', sourcePosition }; + } else { + let text = node.head.text ?? ''; + for (const span of node.templateSpans) { + // Note: + // This magic number 3 is introduced from "${}".length + text += ''.padEnd(span.expression.end - span.expression.pos + 3, ' '); + text += span.literal.text; + } + return { text, sourcePosition }; + } } export function isTemplateLiteralTypeNode(node: ts.Node): node is ts.TemplateLiteralTypeNode { diff --git a/src/types.ts b/src/types.ts index abe2e58a9..5129f8ca7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,8 @@ import { SchemaConfig } from './schema-manager'; export type TsGraphQLPluginConfigOptions = SchemaConfig & { name: string; + exclude?: string[]; + enabledGlobalFragments?: boolean; removeDuplicatedFragments?: boolean; tag?: string; typegen?: {