From 519e36aad29f4f5f567ffd37f18a96b20cd5801d Mon Sep 17 00:00:00 2001 From: Quramy Date: Tue, 5 Mar 2024 22:30:26 +0900 Subject: [PATCH 01/41] wip: Global fragment registry --- src/gql-ast-util/fragment-registry.ts | 37 +++++++++ src/gql-ast-util/index.ts | 12 +++ .../get-completion-at-position.test.ts | 11 +++ .../get-completion-at-position.ts | 8 +- .../get-semantic-diagonistics.test.ts | 55 +++++++++++++ .../get-semantic-diagonistics.ts | 7 +- .../graphql-language-service-adapter.ts | 15 ++-- .../testing/adapter-fixture.ts | 8 ++ src/graphql-language-service-adapter/types.ts | 10 ++- .../document-registry-proxy.ts | 80 +++++++++++++++++++ .../language-service-proxy-builder.ts | 4 +- .../plugin-module-factory.ts | 49 +++++++++++- 12 files changed, 281 insertions(+), 15 deletions(-) create mode 100644 src/gql-ast-util/fragment-registry.ts create mode 100644 src/language-service-plugin/document-registry-proxy.ts diff --git a/src/gql-ast-util/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts new file mode 100644 index 000000000..7243f1b98 --- /dev/null +++ b/src/gql-ast-util/fragment-registry.ts @@ -0,0 +1,37 @@ +import { parse, type DocumentNode, FragmentDefinitionNode } from 'graphql'; + +type FragmentRegistryEntry = { + readonly fileName: string; + readonly fragmentDefinition: FragmentDefinitionNode; +}; + +export class FragmentRegistry { + private _map = new Map(); + + getFragmentDefinitions() { + return [...this._map.values()].map(node => node.fragmentDefinition); + } + + getFragmentDependencies() { + const map = new Map(); + for (const [k, v] of this._map.entries()) { + map.set(k, v.fragmentDefinition); + } + return map; + } + + registerDocument(fileName: string, document: string) { + let docNode: DocumentNode | undefined = undefined; + try { + docNode = parse(document); + } catch {} + if (!docNode) return; + docNode.definitions.forEach(node => { + if (node.kind !== 'FragmentDefinition') return; + this._map.set(node.name.value, { + fileName, + fragmentDefinition: node, + }); + }); + } +} diff --git a/src/gql-ast-util/index.ts b/src/gql-ast-util/index.ts index 8f66a81e4..9e7a0ff3e 100644 --- a/src/gql-ast-util/index.ts +++ b/src/gql-ast-util/index.ts @@ -1,5 +1,17 @@ import { DocumentNode, FragmentDefinitionNode } from 'graphql'; +export * from './fragment-registry'; + +export function getFragmentNamesInDocument(documentNode: DocumentNode) { + const nameSet = new Set(); + for (const def of documentNode.definitions) { + if (def.kind === 'FragmentDefinition') { + nameSet.add(def.name.value); + } + } + return [...nameSet]; +} + export function detectDuplicatedFragments(documentNode: DocumentNode) { const fragments: FragmentDefinitionNode[] = []; const duplicatedFragments: FragmentDefinitionNode[] = []; 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..8b033c83f 100644 --- a/src/graphql-language-service-adapter/get-completion-at-position.ts +++ b/src/graphql-language-service-adapter/get-completion-at-position.ts @@ -49,7 +49,13 @@ export function getCompletionAtPosition( line: innerLocation.line, character: innerLocation.character, }); - const gqlCompletionItems = getAutocompleteSuggestions(schema, combinedText, positionForSeach); + const gqlCompletionItems = getAutocompleteSuggestions( + schema, + combinedText, + positionForSeach, + undefined, + ctx.getFragmentDefinitions(), + ); ctx.debug(JSON.stringify(gqlCompletionItems)); return translateCompletionItems(gqlCompletionItems); } 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..712ab57c3 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.test.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.test.ts @@ -136,6 +136,61 @@ 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 + } + `, + 'fragments.ts', + ); + + fixture.addFragment( + ` + fragment ExternalFragment2 on Query { + __typename + } + `, + 'fragments.ts', + ); + + fixture.source = ` + const fragment = \` + fragment MyFragment on Query { + hello + ...ExternalFragment1 + } + \`; + `; + 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'); diff --git a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts index 681a79390..59aa8ff01 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts @@ -52,11 +52,14 @@ export function getSemanticDiagnostics(ctx: AnalysisContext, delegate: GetSemant }); } else if (schema) { const diagnosticsAndResolvedInfoList = nodes.map(n => { - const { resolvedInfo, resolveErrors } = ctx.resolveTemplateInfo(fileName, n); + const { resolvedInfo, resolveErrors, fragmentNames } = ctx.resolveTemplateInfo(fileName, n); + const externalFragments = ctx.getFragmentDefinitions().filter(def => !fragmentNames.includes(def.name.value)); return { resolveErrors, resolvedTemplateInfo: resolvedInfo, - diagnostics: resolvedInfo ? getDiagnostics(resolvedInfo.combinedText, schema) : [], + diagnostics: resolvedInfo + ? getDiagnostics(resolvedInfo.combinedText, schema, undefined, undefined, externalFragments) + : [], }; }); diagnosticsAndResolvedInfoList.forEach((info, i) => { 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..6478c1af2 100644 --- a/src/graphql-language-service-adapter/graphql-language-service-adapter.ts +++ b/src/graphql-language-service-adapter/graphql-language-service-adapter.ts @@ -2,7 +2,7 @@ import ts from 'typescript'; import { GraphQLSchema, parse } 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'; @@ -14,6 +14,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 +25,7 @@ export class GraphQLLanguageServiceAdapter { private readonly _tagCondition?: TagCondition; private readonly _removeDuplicatedFragments: boolean; private readonly _analysisContext: AnalysisContext; + private readonly _fragmentRegisry: FragmentRegistry; constructor( private readonly _helper: ScriptSourceHelper, @@ -35,6 +37,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 +74,7 @@ export class GraphQLLanguageServiceAdapter { return [this._schema, null]; } }, + getFragmentDefinitions: () => this._fragmentRegisry.getFragmentDefinitions(), findTemplateNode: (fileName, position) => this._findTemplateNode(fileName, position), findTemplateNodes: fileName => this._findTemplateNodes(fileName), resolveTemplateInfo: (fileName, node) => this._resolveTemplateInfo(fileName, node), @@ -114,20 +118,21 @@ export class GraphQLLanguageServiceAdapter { 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 }; + if (!resolvedInfo) return { resolveErrors, fragmentNames: [] }; try { const documentNode = parse(resolvedInfo.combinedText); + 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 }; + return { resolvedInfo: info, resolveErrors, fragmentNames }; } catch (error) { // 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 }; + return { resolvedInfo, resolveErrors, fragmentNames: [] }; } } diff --git a/src/graphql-language-service-adapter/testing/adapter-fixture.ts b/src/graphql-language-service-adapter/testing/adapter-fixture.ts index be742ac02..5ee39d184 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,14 @@ export class AdapterFixture { }); this._sourceFileName = sourceFileName; this._langServiceHost = languageServiceHost; + this._fragmentRegistry = new FragmentRegistry(); this.langService = languageService; this.adapter = new GraphQLLanguageServiceAdapter( createScriptSourceHelper({ languageService, languageServiceHost }), { schema: schema || null, removeDuplicatedFragments: true, + fragmentRegistry: this._fragmentRegistry, }, ); } @@ -36,4 +40,8 @@ export class AdapterFixture { set source(content: string) { this._langServiceHost.updateFile(this._sourceFileName, content); } + + addFragment(fragmentDefDoc: string, sourceFileName = this._sourceFileName) { + this._fragmentRegistry.registerDocument(sourceFileName, fragmentDefDoc); + } } diff --git a/src/graphql-language-service-adapter/types.ts b/src/graphql-language-service-adapter/types.ts index b477ca495..8dc441e72 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 FragmentDefinitionNode } from 'graphql'; import { ScriptSourceHelper, ResolveResult } from '../ts-ast-util'; import { SchemaBuildErrorInfo } from '../schema-manager/schema-manager'; @@ -12,10 +12,16 @@ export interface AnalysisContext { getScriptSourceHelper(): ScriptSourceHelper; getSchema(): GraphQLSchema | null | undefined; getSchemaOrSchemaErrors(): [GraphQLSchema, null] | [null, SchemaBuildErrorInfo[]]; + getFragmentDefinitions(): FragmentDefinitionNode[]; findTemplateNode( fileName: string, position: number, ): ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression | undefined; findTemplateNodes(fileName: string): (ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[]; - resolveTemplateInfo(fileName: string, node: ts.TemplateExpression | ts.NoSubstitutionTemplateLiteral): ResolveResult; + resolveTemplateInfo( + fileName: string, + node: ts.TemplateExpression | ts.NoSubstitutionTemplateLiteral, + ): ResolveResult & { + fragmentNames: string[]; + }; } diff --git a/src/language-service-plugin/document-registry-proxy.ts b/src/language-service-plugin/document-registry-proxy.ts new file mode 100644 index 000000000..f995371ad --- /dev/null +++ b/src/language-service-plugin/document-registry-proxy.ts @@ -0,0 +1,80 @@ +import ts from 'typescript/lib/tsserverlibrary'; + +export type DocumentRegistryProxyCreateOptions = { + readonly delegate: ts.DocumentRegistry; +}; + +export type ScriptChangeEventListener = { + onAcquire: (fileName: string, sourceFile: ts.SourceFile) => void; + onUpdate: (fileName: string, sourceFile: ts.SourceFile) => void; + onRelease: (fileName: string) => void; +}; + +export class DocumentRegistryProxy implements ts.DocumentRegistry { + private readonly _delegate: ts.DocumentRegistry; + public scriptChangeEventListener?: ScriptChangeEventListener; + + constructor({ delegate }: DocumentRegistryProxyCreateOptions) { + this._delegate = delegate; + } + + acquireDocument(...args: Parameters) { + const fileName = args[0]; + const sourceFile = this._delegate.acquireDocument(...args); + this.scriptChangeEventListener?.onAcquire(fileName, sourceFile); + return sourceFile; + } + + acquireDocumentWithKey(...args: Parameters) { + const fileName = args[0]; + const sourceFile = this._delegate.acquireDocumentWithKey(...args); + this.scriptChangeEventListener?.onAcquire(fileName, sourceFile); + return sourceFile; + } + + updateDocument(...args: Parameters) { + const fileName = args[0]; + const sourceFile = this._delegate.updateDocument(...args); + this.scriptChangeEventListener?.onUpdate(fileName, sourceFile); + return sourceFile; + } + + updateDocumentWithKey(...args: Parameters) { + const fileName = args[0]; + const sourceFile = this._delegate.updateDocumentWithKey(...args); + this.scriptChangeEventListener?.onUpdate(fileName, sourceFile); + return sourceFile; + } + + getKeyForCompilationSettings(settings: ts.CompilerOptions) { + return this._delegate.getKeyForCompilationSettings(settings); + } + + releaseDocument(fileName: string, compilationSettings: ts.CompilerOptions, scriptKind?: ts.ScriptKind): void; + releaseDocument( + fileName: string, + compilationSettings: ts.CompilerOptions, + scriptKind: ts.ScriptKind, + impliedNodeFormat: ts.ResolutionMode, + ): void; + releaseDocument(fileName: string, ...args: any[]) { + (this._delegate.releaseDocument as any as Function)(fileName, ...args); + this.scriptChangeEventListener?.onRelease(fileName); + } + + releaseDocumentWithKey(path: ts.Path, key: ts.DocumentRegistryBucketKey, scriptKind?: ts.ScriptKind): void; + releaseDocumentWithKey( + path: ts.Path, + key: ts.DocumentRegistryBucketKey, + scriptKind: ts.ScriptKind, + impliedNodeFormat: ts.ResolutionMode, + ): void; + releaseDocumentWithKey(fileName: string, ...args: any[]) { + (this._delegate.releaseDocumentWithKey as any as Function)(fileName, ...args); + this.scriptChangeEventListener?.onRelease(fileName); + } + + reportStats() { + return this._delegate.reportStats(); + } +} 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..b9bb361be 100644 --- a/src/language-service-plugin/plugin-module-factory.ts +++ b/src/language-service-plugin/plugin-module-factory.ts @@ -1,9 +1,11 @@ import ts from 'typescript/lib/tsserverlibrary'; import { TsGraphQLPluginConfigOptions } from '../types'; import { GraphQLLanguageServiceAdapter } from '../graphql-language-service-adapter'; +import { DocumentRegistryProxy } from './document-registry-proxy'; 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, isTagged, findAllNodes } from '../ts-ast-util'; function create(info: ts.server.PluginCreateInfo): ts.LanguageService { const logger = (msg: string) => info.project.projectService.logger.info(`[ts-graphql-plugin] ${msg}`); @@ -13,15 +15,56 @@ 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 host = info.languageServiceHost; + const docRegistry = new DocumentRegistryProxy({ + delegate: ts.createDocumentRegistry( + host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames(), + host.getCurrentDirectory(), + ), + }); + + const languageService = ts.createLanguageService(info.languageServiceHost, docRegistry); + const fragmentRegistry = new FragmentRegistry(); + const scriptSourceHelper = createScriptSourceHelper({ ...info, languageService }); + const adapter = new GraphQLLanguageServiceAdapter(scriptSourceHelper, { schema, schemaErrors, logger, tag, + fragmentRegistry, removeDuplicatedFragments, }); - const proxy = new LanguageServiceProxyBuilder(info) + docRegistry.scriptChangeEventListener = { + onAcquire: (fileName, sourceFile) => { + if (host.getScriptFileNames().includes(fileName)) { + // TODO remove before merge + logger('acquire script ' + fileName); + + const templateLiteralNodes = findAllNodes(sourceFile, node => { + // TODO handle TemplateExpression + if (ts.isNoSubstitutionTemplateLiteral(node)) { + return true; + } + if (!tag) return true; + return !!isTagged(node, tag); + }) as ts.NoSubstitutionTemplateLiteral[]; + templateLiteralNodes.forEach(node => { + if (!node.rawText) return; + fragmentRegistry.registerDocument(fileName, node.rawText); + }); + } + }, + onUpdate: () => { + // TODO + }, + onRelease: () => { + // TODO + }, + }; + + const proxy = new LanguageServiceProxyBuilder({ languageService }) .wrap('getCompletionsAtPosition', delegate => adapter.getCompletionAtPosition.bind(adapter, delegate)) .wrap('getSemanticDiagnostics', delegate => adapter.getSemanticDiagnostics.bind(adapter, delegate)) .wrap('getQuickInfoAtPosition', delegate => adapter.getQuickInfoAtPosition.bind(adapter, delegate)) From 518f2af076374543d32e96041f98f2a886c75613 Mon Sep 17 00:00:00 2001 From: Quramy Date: Tue, 5 Mar 2024 23:57:04 +0900 Subject: [PATCH 02/41] refactor: Modify FragmentRegistry's methods --- ...ts.snap => utility-functions.test.ts.snap} | 0 src/gql-ast-util/fragment-registry.ts | 42 ++++++++---------- src/gql-ast-util/index.ts | 36 +-------------- ...ndex.test.ts => utility-functions.test.ts} | 2 +- src/gql-ast-util/utility-functions.ts | 44 +++++++++++++++++++ .../get-completion-at-position.ts | 2 +- .../get-semantic-diagonistics.test.ts | 4 +- .../get-semantic-diagonistics.ts | 2 +- .../graphql-language-service-adapter.ts | 3 +- .../testing/adapter-fixture.ts | 2 +- src/graphql-language-service-adapter/types.ts | 2 +- .../document-registry-proxy.ts | 20 ++++----- .../plugin-module-factory.ts | 31 ++++++++++--- 13 files changed, 107 insertions(+), 83 deletions(-) rename src/gql-ast-util/__snapshots__/{index.test.ts.snap => utility-functions.test.ts.snap} (100%) rename src/gql-ast-util/{index.test.ts => utility-functions.test.ts} (94%) create mode 100644 src/gql-ast-util/utility-functions.ts 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.ts b/src/gql-ast-util/fragment-registry.ts index 7243f1b98..00136cc56 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -1,37 +1,33 @@ import { parse, type DocumentNode, FragmentDefinitionNode } from 'graphql'; - -type FragmentRegistryEntry = { - readonly fileName: string; - readonly fragmentDefinition: FragmentDefinitionNode; -}; +import { getFragmentsInDocument } from './utility-functions'; export class FragmentRegistry { - private _map = new Map(); + private _fileVersionMap = new Map(); + private _fragmentsMap = new Map(); - getFragmentDefinitions() { - return [...this._map.values()].map(node => node.fragmentDefinition); + getFileCurrentVersion(fileName: string): string | undefined { + return this._fileVersionMap.get(fileName); } - getFragmentDependencies() { - const map = new Map(); - for (const [k, v] of this._map.entries()) { - map.set(k, v.fragmentDefinition); - } - return map; + getFragmentDefinitions(fragmentNamesToBeIgnored: string[] = []): FragmentDefinitionNode[] { + return [...this._fragmentsMap.values()].flat().filter(def => !fragmentNamesToBeIgnored.includes(def.name.value)); } - registerDocument(fileName: string, document: string) { + registerDocument(fileName: string, version: string, document: string): void { let docNode: DocumentNode | undefined = undefined; + this._fileVersionMap.set(fileName, version); try { docNode = parse(document); } catch {} - if (!docNode) return; - docNode.definitions.forEach(node => { - if (node.kind !== 'FragmentDefinition') return; - this._map.set(node.name.value, { - fileName, - fragmentDefinition: node, - }); - }); + if (!docNode) { + this._fragmentsMap.set(fileName, []); + return; + } + this._fragmentsMap.set(fileName, getFragmentsInDocument(docNode)); + } + + removeDocument(fileName: string): void { + this._fileVersionMap.delete(fileName); + this._fragmentsMap.delete(fileName); } } diff --git a/src/gql-ast-util/index.ts b/src/gql-ast-util/index.ts index 9e7a0ff3e..2511683f0 100644 --- a/src/gql-ast-util/index.ts +++ b/src/gql-ast-util/index.ts @@ -1,36 +1,2 @@ -import { DocumentNode, FragmentDefinitionNode } from 'graphql'; - export * from './fragment-registry'; - -export function getFragmentNamesInDocument(documentNode: DocumentNode) { - const nameSet = new Set(); - for (const def of documentNode.definitions) { - if (def.kind === 'FragmentDefinition') { - nameSet.add(def.name.value); - } - } - return [...nameSet]; -} - -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 './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..d97f22604 100644 --- a/src/gql-ast-util/index.test.ts +++ b/src/gql-ast-util/utility-functions.test.ts @@ -1,4 +1,4 @@ -import { detectDuplicatedFragments } from './'; +import { detectDuplicatedFragments } from './utility-functions'; import { parse } from 'graphql'; describe(detectDuplicatedFragments, () => { diff --git a/src/gql-ast-util/utility-functions.ts b/src/gql-ast-util/utility-functions.ts new file mode 100644 index 000000000..a521df315 --- /dev/null +++ b/src/gql-ast-util/utility-functions.ts @@ -0,0 +1,44 @@ +import { DocumentNode, FragmentDefinitionNode } from 'graphql'; + +export function getFragmentsInDocument(documentNode: DocumentNode) { + const nameSet = new Set(); + for (const def of documentNode.definitions) { + if (def.kind === 'FragmentDefinition') { + nameSet.add(def); + } + } + return [...nameSet]; +} + +export function getFragmentNamesInDocument(documentNode: DocumentNode) { + const nameSet = new Set(); + for (const def of documentNode.definitions) { + if (def.kind === 'FragmentDefinition') { + nameSet.add(def.name.value); + } + } + return [...nameSet]; +} + +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.ts b/src/graphql-language-service-adapter/get-completion-at-position.ts index 8b033c83f..839f60d58 100644 --- a/src/graphql-language-service-adapter/get-completion-at-position.ts +++ b/src/graphql-language-service-adapter/get-completion-at-position.ts @@ -54,7 +54,7 @@ export function getCompletionAtPosition( combinedText, positionForSeach, undefined, - ctx.getFragmentDefinitions(), + ctx.getGlobalFragmentDefinitions(), ); ctx.debug(JSON.stringify(gqlCompletionItems)); return translateCompletionItems(gqlCompletionItems); 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 712ab57c3..5442879b8 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.test.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.test.ts @@ -167,7 +167,7 @@ describe('getSemanticDiagnostics', () => { __typename } `, - 'fragments.ts', + 'fragment1.ts', ); fixture.addFragment( @@ -176,7 +176,7 @@ describe('getSemanticDiagnostics', () => { __typename } `, - 'fragments.ts', + 'fragment2.ts', ); fixture.source = ` diff --git a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts index 59aa8ff01..b819a6a5f 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts @@ -53,7 +53,7 @@ export function getSemanticDiagnostics(ctx: AnalysisContext, delegate: GetSemant } else if (schema) { const diagnosticsAndResolvedInfoList = nodes.map(n => { const { resolvedInfo, resolveErrors, fragmentNames } = ctx.resolveTemplateInfo(fileName, n); - const externalFragments = ctx.getFragmentDefinitions().filter(def => !fragmentNames.includes(def.name.value)); + const externalFragments = ctx.getGlobalFragmentDefinitions(fragmentNames); return { resolveErrors, resolvedTemplateInfo: resolvedInfo, 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 6478c1af2..0fde306ab 100644 --- a/src/graphql-language-service-adapter/graphql-language-service-adapter.ts +++ b/src/graphql-language-service-adapter/graphql-language-service-adapter.ts @@ -74,7 +74,8 @@ export class GraphQLLanguageServiceAdapter { return [this._schema, null]; } }, - getFragmentDefinitions: () => this._fragmentRegisry.getFragmentDefinitions(), + getGlobalFragmentDefinitions: fragmentNamesToBeIgnored => + this._fragmentRegisry.getFragmentDefinitions(fragmentNamesToBeIgnored), findTemplateNode: (fileName, position) => this._findTemplateNode(fileName, position), findTemplateNodes: fileName => this._findTemplateNodes(fileName), resolveTemplateInfo: (fileName, node) => this._resolveTemplateInfo(fileName, node), diff --git a/src/graphql-language-service-adapter/testing/adapter-fixture.ts b/src/graphql-language-service-adapter/testing/adapter-fixture.ts index 5ee39d184..f291cef83 100644 --- a/src/graphql-language-service-adapter/testing/adapter-fixture.ts +++ b/src/graphql-language-service-adapter/testing/adapter-fixture.ts @@ -42,6 +42,6 @@ export class AdapterFixture { } addFragment(fragmentDefDoc: string, sourceFileName = this._sourceFileName) { - this._fragmentRegistry.registerDocument(sourceFileName, fragmentDefDoc); + this._fragmentRegistry.registerDocument(sourceFileName, 'v1', fragmentDefDoc); } } diff --git a/src/graphql-language-service-adapter/types.ts b/src/graphql-language-service-adapter/types.ts index 8dc441e72..f117bf07f 100644 --- a/src/graphql-language-service-adapter/types.ts +++ b/src/graphql-language-service-adapter/types.ts @@ -12,7 +12,7 @@ export interface AnalysisContext { getScriptSourceHelper(): ScriptSourceHelper; getSchema(): GraphQLSchema | null | undefined; getSchemaOrSchemaErrors(): [GraphQLSchema, null] | [null, SchemaBuildErrorInfo[]]; - getFragmentDefinitions(): FragmentDefinitionNode[]; + getGlobalFragmentDefinitions(fragmentNamesToBeIgnored?: string[]): FragmentDefinitionNode[]; findTemplateNode( fileName: string, position: number, diff --git a/src/language-service-plugin/document-registry-proxy.ts b/src/language-service-plugin/document-registry-proxy.ts index f995371ad..c1e6da02e 100644 --- a/src/language-service-plugin/document-registry-proxy.ts +++ b/src/language-service-plugin/document-registry-proxy.ts @@ -5,8 +5,8 @@ export type DocumentRegistryProxyCreateOptions = { }; export type ScriptChangeEventListener = { - onAcquire: (fileName: string, sourceFile: ts.SourceFile) => void; - onUpdate: (fileName: string, sourceFile: ts.SourceFile) => void; + onAcquire: (fileName: string, sourceFile: ts.SourceFile, version: string) => void; + onUpdate: (fileName: string, sourceFile: ts.SourceFile, version: string) => void; onRelease: (fileName: string) => void; }; @@ -19,30 +19,30 @@ export class DocumentRegistryProxy implements ts.DocumentRegistry { } acquireDocument(...args: Parameters) { - const fileName = args[0]; + const [fileName, , , version] = args; const sourceFile = this._delegate.acquireDocument(...args); - this.scriptChangeEventListener?.onAcquire(fileName, sourceFile); + this.scriptChangeEventListener?.onAcquire(fileName, sourceFile, version); return sourceFile; } acquireDocumentWithKey(...args: Parameters) { - const fileName = args[0]; + const [fileName, , , , , version] = args; const sourceFile = this._delegate.acquireDocumentWithKey(...args); - this.scriptChangeEventListener?.onAcquire(fileName, sourceFile); + this.scriptChangeEventListener?.onAcquire(fileName, sourceFile, version); return sourceFile; } updateDocument(...args: Parameters) { - const fileName = args[0]; + const [fileName, , , version] = args; const sourceFile = this._delegate.updateDocument(...args); - this.scriptChangeEventListener?.onUpdate(fileName, sourceFile); + this.scriptChangeEventListener?.onUpdate(fileName, sourceFile, version); return sourceFile; } updateDocumentWithKey(...args: Parameters) { - const fileName = args[0]; + const [fileName, , , , , version] = args; const sourceFile = this._delegate.updateDocumentWithKey(...args); - this.scriptChangeEventListener?.onUpdate(fileName, sourceFile); + this.scriptChangeEventListener?.onUpdate(fileName, sourceFile, version); return sourceFile; } diff --git a/src/language-service-plugin/plugin-module-factory.ts b/src/language-service-plugin/plugin-module-factory.ts index b9bb361be..3369e3d6d 100644 --- a/src/language-service-plugin/plugin-module-factory.ts +++ b/src/language-service-plugin/plugin-module-factory.ts @@ -37,10 +37,10 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { }); docRegistry.scriptChangeEventListener = { - onAcquire: (fileName, sourceFile) => { + onAcquire: (fileName, sourceFile, version) => { if (host.getScriptFileNames().includes(fileName)) { // TODO remove before merge - logger('acquire script ' + fileName); + logger('acquire script ' + fileName + version); const templateLiteralNodes = findAllNodes(sourceFile, node => { // TODO handle TemplateExpression @@ -52,15 +52,32 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { }) as ts.NoSubstitutionTemplateLiteral[]; templateLiteralNodes.forEach(node => { if (!node.rawText) return; - fragmentRegistry.registerDocument(fileName, node.rawText); + fragmentRegistry.registerDocument(fileName, version, node.rawText); }); } }, - onUpdate: () => { - // TODO + onUpdate: (fileName, sourceFile, version) => { + if (host.getScriptFileNames().includes(fileName)) { + if (fragmentRegistry.getFileCurrentVersion(fileName) === version) return; + // TODO remove before merge + logger('update script ' + fileName + version); + + const templateLiteralNodes = findAllNodes(sourceFile, node => { + // TODO handle TemplateExpression + if (ts.isNoSubstitutionTemplateLiteral(node)) { + return true; + } + if (!tag) return true; + return !!isTagged(node, tag); + }) as ts.NoSubstitutionTemplateLiteral[]; + templateLiteralNodes.forEach(node => { + if (!node.rawText) return; + fragmentRegistry.registerDocument(fileName, version, node.rawText); + }); + } }, - onRelease: () => { - // TODO + onRelease: fileName => { + fragmentRegistry.removeDocument(fileName); }, }; From 6a4b5cea9c85112151a8ae51d1abc6503d0d28f9 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 6 Mar 2024 00:11:18 +0900 Subject: [PATCH 03/41] fix: DocumentRegistry event listener --- src/gql-ast-util/fragment-registry.ts | 23 +++++++++++-------- .../testing/adapter-fixture.ts | 2 +- .../plugin-module-factory.ts | 18 ++++++++------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/gql-ast-util/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts index 00136cc56..23e7e56ad 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -13,17 +13,20 @@ export class FragmentRegistry { return [...this._fragmentsMap.values()].flat().filter(def => !fragmentNamesToBeIgnored.includes(def.name.value)); } - registerDocument(fileName: string, version: string, document: string): void { - let docNode: DocumentNode | undefined = undefined; - this._fileVersionMap.set(fileName, version); - try { - docNode = parse(document); - } catch {} - if (!docNode) { - this._fragmentsMap.set(fileName, []); - return; + registerDocument(fileName: string, version: string, documentStrings: string[]): void { + const definitions: FragmentDefinitionNode[] = []; + for (const document of documentStrings) { + let docNode: DocumentNode | undefined = undefined; + this._fileVersionMap.set(fileName, version); + try { + docNode = parse(document); + } catch {} + if (!docNode) { + continue; + } + definitions.push(...getFragmentsInDocument(docNode)); } - this._fragmentsMap.set(fileName, getFragmentsInDocument(docNode)); + this._fragmentsMap.set(fileName, definitions); } removeDocument(fileName: 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 f291cef83..af03c9d53 100644 --- a/src/graphql-language-service-adapter/testing/adapter-fixture.ts +++ b/src/graphql-language-service-adapter/testing/adapter-fixture.ts @@ -42,6 +42,6 @@ export class AdapterFixture { } addFragment(fragmentDefDoc: string, sourceFileName = this._sourceFileName) { - this._fragmentRegistry.registerDocument(sourceFileName, 'v1', fragmentDefDoc); + this._fragmentRegistry.registerDocument(sourceFileName, 'v1', [fragmentDefDoc]); } } diff --git a/src/language-service-plugin/plugin-module-factory.ts b/src/language-service-plugin/plugin-module-factory.ts index 3369e3d6d..90b28e33a 100644 --- a/src/language-service-plugin/plugin-module-factory.ts +++ b/src/language-service-plugin/plugin-module-factory.ts @@ -50,10 +50,11 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { if (!tag) return true; return !!isTagged(node, tag); }) as ts.NoSubstitutionTemplateLiteral[]; - templateLiteralNodes.forEach(node => { - if (!node.rawText) return; - fragmentRegistry.registerDocument(fileName, version, node.rawText); - }); + fragmentRegistry.registerDocument( + fileName, + version, + templateLiteralNodes.reduce((docs, node) => (node.rawText ? [...docs, node.rawText] : docs), [] as string[]), + ); } }, onUpdate: (fileName, sourceFile, version) => { @@ -70,10 +71,11 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { if (!tag) return true; return !!isTagged(node, tag); }) as ts.NoSubstitutionTemplateLiteral[]; - templateLiteralNodes.forEach(node => { - if (!node.rawText) return; - fragmentRegistry.registerDocument(fileName, version, node.rawText); - }); + fragmentRegistry.registerDocument( + fileName, + version, + templateLiteralNodes.reduce((docs, node) => (node.rawText ? [...docs, node.rawText] : docs), [] as string[]), + ); } }, onRelease: fileName => { From 3eea17da54dd99b9d28a74b7851054f4f8469f56 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 6 Mar 2024 11:58:46 +0900 Subject: [PATCH 04/41] fix: Don't re-create DocumentRegistry instance --- .../document-registry-proxy.ts | 80 ------------ .../plugin-module-factory.ts | 122 +++++++++--------- .../register-document-change-event.ts | 64 +++++++++ 3 files changed, 126 insertions(+), 140 deletions(-) delete mode 100644 src/language-service-plugin/document-registry-proxy.ts create mode 100644 src/language-service-plugin/register-document-change-event.ts diff --git a/src/language-service-plugin/document-registry-proxy.ts b/src/language-service-plugin/document-registry-proxy.ts deleted file mode 100644 index c1e6da02e..000000000 --- a/src/language-service-plugin/document-registry-proxy.ts +++ /dev/null @@ -1,80 +0,0 @@ -import ts from 'typescript/lib/tsserverlibrary'; - -export type DocumentRegistryProxyCreateOptions = { - readonly delegate: ts.DocumentRegistry; -}; - -export type ScriptChangeEventListener = { - onAcquire: (fileName: string, sourceFile: ts.SourceFile, version: string) => void; - onUpdate: (fileName: string, sourceFile: ts.SourceFile, version: string) => void; - onRelease: (fileName: string) => void; -}; - -export class DocumentRegistryProxy implements ts.DocumentRegistry { - private readonly _delegate: ts.DocumentRegistry; - public scriptChangeEventListener?: ScriptChangeEventListener; - - constructor({ delegate }: DocumentRegistryProxyCreateOptions) { - this._delegate = delegate; - } - - acquireDocument(...args: Parameters) { - const [fileName, , , version] = args; - const sourceFile = this._delegate.acquireDocument(...args); - this.scriptChangeEventListener?.onAcquire(fileName, sourceFile, version); - return sourceFile; - } - - acquireDocumentWithKey(...args: Parameters) { - const [fileName, , , , , version] = args; - const sourceFile = this._delegate.acquireDocumentWithKey(...args); - this.scriptChangeEventListener?.onAcquire(fileName, sourceFile, version); - return sourceFile; - } - - updateDocument(...args: Parameters) { - const [fileName, , , version] = args; - const sourceFile = this._delegate.updateDocument(...args); - this.scriptChangeEventListener?.onUpdate(fileName, sourceFile, version); - return sourceFile; - } - - updateDocumentWithKey(...args: Parameters) { - const [fileName, , , , , version] = args; - const sourceFile = this._delegate.updateDocumentWithKey(...args); - this.scriptChangeEventListener?.onUpdate(fileName, sourceFile, version); - return sourceFile; - } - - getKeyForCompilationSettings(settings: ts.CompilerOptions) { - return this._delegate.getKeyForCompilationSettings(settings); - } - - releaseDocument(fileName: string, compilationSettings: ts.CompilerOptions, scriptKind?: ts.ScriptKind): void; - releaseDocument( - fileName: string, - compilationSettings: ts.CompilerOptions, - scriptKind: ts.ScriptKind, - impliedNodeFormat: ts.ResolutionMode, - ): void; - releaseDocument(fileName: string, ...args: any[]) { - (this._delegate.releaseDocument as any as Function)(fileName, ...args); - this.scriptChangeEventListener?.onRelease(fileName); - } - - releaseDocumentWithKey(path: ts.Path, key: ts.DocumentRegistryBucketKey, scriptKind?: ts.ScriptKind): void; - releaseDocumentWithKey( - path: ts.Path, - key: ts.DocumentRegistryBucketKey, - scriptKind: ts.ScriptKind, - impliedNodeFormat: ts.ResolutionMode, - ): void; - releaseDocumentWithKey(fileName: string, ...args: any[]) { - (this._delegate.releaseDocumentWithKey as any as Function)(fileName, ...args); - this.scriptChangeEventListener?.onRelease(fileName); - } - - reportStats() { - return this._delegate.reportStats(); - } -} diff --git a/src/language-service-plugin/plugin-module-factory.ts b/src/language-service-plugin/plugin-module-factory.ts index 90b28e33a..7b9733ead 100644 --- a/src/language-service-plugin/plugin-module-factory.ts +++ b/src/language-service-plugin/plugin-module-factory.ts @@ -1,11 +1,11 @@ import ts from 'typescript/lib/tsserverlibrary'; import { TsGraphQLPluginConfigOptions } from '../types'; import { GraphQLLanguageServiceAdapter } from '../graphql-language-service-adapter'; -import { DocumentRegistryProxy } from './document-registry-proxy'; -import { LanguageServiceProxyBuilder } from './language-service-proxy-builder'; import { SchemaManagerFactory, createSchemaManagerHostFromLSPluginInfo } from '../schema-manager'; import { FragmentRegistry } from '../gql-ast-util'; import { createScriptSourceHelper, isTagged, findAllNodes } from '../ts-ast-util'; +import { registerDocumentChangeEvent } from './register-document-change-event'; +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}`); @@ -16,17 +16,66 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { const tag = config.tag; const removeDuplicatedFragments = config.removeDuplicatedFragments === false ? false : true; - const host = info.languageServiceHost; - const docRegistry = new DocumentRegistryProxy({ - delegate: ts.createDocumentRegistry( - host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames(), - host.getCurrentDirectory(), - ), - }); - - const languageService = ts.createLanguageService(info.languageServiceHost, docRegistry); const fragmentRegistry = new FragmentRegistry(); - const scriptSourceHelper = createScriptSourceHelper({ ...info, languageService }); + registerDocumentChangeEvent( + // Note: + // documentRegistry in ts.server.Project is annotated @internal + (info.project as any).documentRegistry as ts.DocumentRegistry, + { + onAcquire: (fileName, sourceFile, version) => { + if (info.languageServiceHost.getScriptFileNames().includes(fileName)) { + // TODO remove before merge + logger('acquire script ' + fileName + version); + + const templateLiteralNodes = findAllNodes(sourceFile, node => { + // TODO handle TemplateExpression + if (ts.isNoSubstitutionTemplateLiteral(node)) { + return true; + } + if (!tag) return true; + return !!isTagged(node, tag); + }) as ts.NoSubstitutionTemplateLiteral[]; + fragmentRegistry.registerDocument( + fileName, + version, + templateLiteralNodes.reduce( + (docs, node) => (node.rawText ? [...docs, node.rawText] : docs), + [] as string[], + ), + ); + } + }, + onUpdate: (fileName, sourceFile, version) => { + if (info.languageServiceHost.getScriptFileNames().includes(fileName)) { + if (fragmentRegistry.getFileCurrentVersion(fileName) === version) return; + // TODO remove before merge + logger('update script ' + fileName + version); + + const templateLiteralNodes = findAllNodes(sourceFile, node => { + // TODO handle TemplateExpression + if (ts.isNoSubstitutionTemplateLiteral(node)) { + return true; + } + if (!tag) return true; + return !!isTagged(node, tag); + }) as ts.NoSubstitutionTemplateLiteral[]; + fragmentRegistry.registerDocument( + fileName, + version, + templateLiteralNodes.reduce( + (docs, node) => (node.rawText ? [...docs, node.rawText] : docs), + [] as string[], + ), + ); + } + }, + onRelease: fileName => { + fragmentRegistry.removeDocument(fileName); + }, + }, + ); + + const scriptSourceHelper = createScriptSourceHelper(info); const adapter = new GraphQLLanguageServiceAdapter(scriptSourceHelper, { schema, schemaErrors, @@ -36,54 +85,7 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { removeDuplicatedFragments, }); - docRegistry.scriptChangeEventListener = { - onAcquire: (fileName, sourceFile, version) => { - if (host.getScriptFileNames().includes(fileName)) { - // TODO remove before merge - logger('acquire script ' + fileName + version); - - const templateLiteralNodes = findAllNodes(sourceFile, node => { - // TODO handle TemplateExpression - if (ts.isNoSubstitutionTemplateLiteral(node)) { - return true; - } - if (!tag) return true; - return !!isTagged(node, tag); - }) as ts.NoSubstitutionTemplateLiteral[]; - fragmentRegistry.registerDocument( - fileName, - version, - templateLiteralNodes.reduce((docs, node) => (node.rawText ? [...docs, node.rawText] : docs), [] as string[]), - ); - } - }, - onUpdate: (fileName, sourceFile, version) => { - if (host.getScriptFileNames().includes(fileName)) { - if (fragmentRegistry.getFileCurrentVersion(fileName) === version) return; - // TODO remove before merge - logger('update script ' + fileName + version); - - const templateLiteralNodes = findAllNodes(sourceFile, node => { - // TODO handle TemplateExpression - if (ts.isNoSubstitutionTemplateLiteral(node)) { - return true; - } - if (!tag) return true; - return !!isTagged(node, tag); - }) as ts.NoSubstitutionTemplateLiteral[]; - fragmentRegistry.registerDocument( - fileName, - version, - templateLiteralNodes.reduce((docs, node) => (node.rawText ? [...docs, node.rawText] : docs), [] as string[]), - ); - } - }, - onRelease: fileName => { - fragmentRegistry.removeDocument(fileName); - }, - }; - - const proxy = new LanguageServiceProxyBuilder({ languageService }) + const proxy = new LanguageServiceProxyBuilder(info) .wrap('getCompletionsAtPosition', delegate => adapter.getCompletionAtPosition.bind(adapter, delegate)) .wrap('getSemanticDiagnostics', delegate => adapter.getSemanticDiagnostics.bind(adapter, delegate)) .wrap('getQuickInfoAtPosition', delegate => adapter.getQuickInfoAtPosition.bind(adapter, delegate)) diff --git a/src/language-service-plugin/register-document-change-event.ts b/src/language-service-plugin/register-document-change-event.ts new file mode 100644 index 000000000..88b478b3d --- /dev/null +++ b/src/language-service-plugin/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); + }, + }); +} From 22313c758e01840612e84ddb288be59afffdc8d6 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 6 Mar 2024 14:37:17 +0900 Subject: [PATCH 05/41] feat: Validate command with external fragments --- .../__snapshots__/analyzer.test.ts.snap | 18 ++++++ src/analyzer/analyzer.test.ts | 57 ++++++++++++++----- src/analyzer/validator.ts | 13 ++++- src/gql-ast-util/utility-functions.ts | 12 ++-- 4 files changed, 81 insertions(+), 19 deletions(-) diff --git a/src/analyzer/__snapshots__/analyzer.test.ts.snap b/src/analyzer/__snapshots__/analyzer.test.ts.snap index 8ab047c3f..648d617c3 100644 --- a/src/analyzer/__snapshots__/analyzer.test.ts.snap +++ b/src/analyzer/__snapshots__/analyzer.test.ts.snap @@ -72,6 +72,24 @@ From [main.ts:1:19](main.ts#L1-L1) Extracted by [ts-graphql-plugin](https://github.com/Quramy/ts-graphql-plugin)" `; +exports[`Analyzer report should create markdown report with external fragments 1`] = ` +"# Extracted GraphQL Operations +## Queries + +### MyQuery + +\`\`\`graphql +query MyQuery { + ...MyFragment +} +\`\`\` + +From [main.ts:1:19](main.ts#L1-L1) + +--- +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! */ diff --git a/src/analyzer/analyzer.test.ts b/src/analyzer/analyzer.test.ts index 8d03a1ea8..fa45b8f5b 100644 --- a/src/analyzer/analyzer.test.ts +++ b/src/analyzer/analyzer.test.ts @@ -43,9 +43,9 @@ function createTestingAnalyzer({ files: sourceFiles = [], sdl, localSchemaExtens const simpleSources = { sdl: ` - type Query { - hello: String! - } + type Query { + hello: String! + } `, files: [ { @@ -55,6 +55,24 @@ const simpleSources = { ], }; +const externalFragmentsPrj = { + 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 noSchemaPrj = { sdl: '', files: [ @@ -67,9 +85,9 @@ const noSchemaPrj = { const extensionErrorPrj = { sdl: ` - type Query { - hello: String! - } + type Query { + hello: String! + } `, files: [ { @@ -85,9 +103,9 @@ const extensionErrorPrj = { const semanticErrorPrj = { sdl: ` - type Query { - hello: String! - } + type Query { + hello: String! + } `, files: [ { @@ -99,9 +117,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: [ { @@ -149,12 +167,18 @@ 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 work with external fragments', async () => { + const analyzer = createTestingAnalyzer(externalFragmentsPrj); + const { errors } = await analyzer.validate(); + expect(errors.length).toBe(0); + }); }); describe(Analyzer.prototype.report, () => { @@ -165,6 +189,13 @@ describe(Analyzer, () => { expect(output).toMatchSnapshot(); }); + it('should create markdown report with external fragments', () => { + const analyzer = createTestingAnalyzer(externalFragmentsPrj); + const [errors, output] = analyzer.report('out.md'); + expect(errors.length).toBe(0); + expect(output).toMatchSnapshot(); + }); + it('should create markdown report from manifest', () => { const analyzer = createTestingAnalyzer(simpleSources); const manifestOutput = analyzer.extractToManifest(); diff --git a/src/analyzer/validator.ts b/src/analyzer/validator.ts index 83ba3f0e9..8b2532da0 100644 --- a/src/analyzer/validator.ts +++ b/src/analyzer/validator.ts @@ -2,13 +2,24 @@ import { GraphQLSchema } from 'graphql'; import { getDiagnostics } from 'graphql-language-service'; import { ExtractResult } from './extractor'; import { ErrorWithLocation } from '../errors'; +import { getFragmentsInDocument, getFragmentNamesInDocument } from '../gql-ast-util'; export function validate(extractedResults: ExtractResult[], schema: GraphQLSchema) { const errors: ErrorWithLocation[] = []; + const globalFragmentDefinitions = extractedResults.flatMap(({ documentNode }) => + getFragmentsInDocument(documentNode), + ); extractedResults.forEach(r => { if (!r.resolevedTemplateInfo) return; const { combinedText, getSourcePosition, convertInnerLocation2InnerPosition } = r.resolevedTemplateInfo; - const diagnostics = getDiagnostics(combinedText, schema); + const fragmentNamesInText = getFragmentNamesInDocument(r.documentNode); + const diagnostics = getDiagnostics( + combinedText, + schema, + undefined, + undefined, + globalFragmentDefinitions.filter(def => !fragmentNamesInText.includes(def.name.value)), + ); diagnostics.forEach(diagnositc => { const { pos: startPositionOfSource, isInOtherExpression } = getSourcePosition( convertInnerLocation2InnerPosition(diagnositc.range.start), diff --git a/src/gql-ast-util/utility-functions.ts b/src/gql-ast-util/utility-functions.ts index a521df315..b15a60a44 100644 --- a/src/gql-ast-util/utility-functions.ts +++ b/src/gql-ast-util/utility-functions.ts @@ -1,16 +1,18 @@ import { DocumentNode, FragmentDefinitionNode } from 'graphql'; -export function getFragmentsInDocument(documentNode: DocumentNode) { - const nameSet = new Set(); +export function getFragmentsInDocument(documentNode: DocumentNode | undefined) { + if (!documentNode) return []; + const fragmentDefs = new Set(); for (const def of documentNode.definitions) { if (def.kind === 'FragmentDefinition') { - nameSet.add(def); + fragmentDefs.add(def); } } - return [...nameSet]; + return [...fragmentDefs]; } -export function getFragmentNamesInDocument(documentNode: DocumentNode) { +export function getFragmentNamesInDocument(documentNode: DocumentNode | undefined) { + if (!documentNode) return []; const nameSet = new Set(); for (const def of documentNode.definitions) { if (def.kind === 'FragmentDefinition') { From f1012a9c986c3b715f3a7bbe9abe3da4640197a0 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 6 Mar 2024 15:14:33 +0900 Subject: [PATCH 06/41] fix: Fix logic to extract global fragments --- src/analyzer/validator.ts | 4 +--- src/gql-ast-util/utility-functions.ts | 28 +++++++++++++++------------ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/analyzer/validator.ts b/src/analyzer/validator.ts index 8b2532da0..fbbd00581 100644 --- a/src/analyzer/validator.ts +++ b/src/analyzer/validator.ts @@ -6,9 +6,7 @@ import { getFragmentsInDocument, getFragmentNamesInDocument } from '../gql-ast-u export function validate(extractedResults: ExtractResult[], schema: GraphQLSchema) { const errors: ErrorWithLocation[] = []; - const globalFragmentDefinitions = extractedResults.flatMap(({ documentNode }) => - getFragmentsInDocument(documentNode), - ); + const globalFragmentDefinitions = getFragmentsInDocument(...extractedResults.map(({ documentNode }) => documentNode)); extractedResults.forEach(r => { if (!r.resolevedTemplateInfo) return; const { combinedText, getSourcePosition, convertInnerLocation2InnerPosition } = r.resolevedTemplateInfo; diff --git a/src/gql-ast-util/utility-functions.ts b/src/gql-ast-util/utility-functions.ts index b15a60a44..4336da333 100644 --- a/src/gql-ast-util/utility-functions.ts +++ b/src/gql-ast-util/utility-functions.ts @@ -1,22 +1,26 @@ import { DocumentNode, FragmentDefinitionNode } from 'graphql'; -export function getFragmentsInDocument(documentNode: DocumentNode | undefined) { - if (!documentNode) return []; - const fragmentDefs = new Set(); - for (const def of documentNode.definitions) { - if (def.kind === 'FragmentDefinition') { - fragmentDefs.add(def); +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]; + return [...fragmentDefs.values()]; } -export function getFragmentNamesInDocument(documentNode: DocumentNode | undefined) { - if (!documentNode) return []; +export function getFragmentNamesInDocument(...documentNodes: (DocumentNode | undefined)[]) { const nameSet = new Set(); - for (const def of documentNode.definitions) { - if (def.kind === 'FragmentDefinition') { - nameSet.add(def.name.value); + 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]; From 7fae66c731cf047d5c1c1ac6da743f8eef904a4e Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 6 Mar 2024 15:22:01 +0900 Subject: [PATCH 07/41] test: Add analyzer spec for template expression --- src/analyzer/analyzer.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/analyzer/analyzer.test.ts b/src/analyzer/analyzer.test.ts index fa45b8f5b..2e871ec9c 100644 --- a/src/analyzer/analyzer.test.ts +++ b/src/analyzer/analyzer.test.ts @@ -73,6 +73,29 @@ const externalFragmentsPrj = { ], }; +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: [ @@ -174,6 +197,12 @@ describe(Analyzer, () => { expect(schema).toBeTruthy(); }); + 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 work with external fragments', async () => { const analyzer = createTestingAnalyzer(externalFragmentsPrj); const { errors } = await analyzer.validate(); From 47394133c78650c5deecb6ce915d7e543243d389 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 6 Mar 2024 18:15:06 +0900 Subject: [PATCH 08/41] refactor: Change extractor result type --- src/analyzer/analyzer.ts | 6 ++-- src/analyzer/extractor.test.ts | 42 +++++++++++++-------------- src/analyzer/extractor.ts | 18 ++++++++---- src/analyzer/index.ts | 2 +- src/analyzer/type-generator.test.ts | 6 ++-- src/analyzer/type-generator.ts | 44 ++++++++++++++--------------- src/analyzer/validator.ts | 2 +- src/transformer/transformer-host.ts | 12 ++++---- 8 files changed, 70 insertions(+), 62 deletions(-) diff --git a/src/analyzer/analyzer.ts b/src/analyzer/analyzer.ts index ea37af479..f7ad4dc44 100644 --- a/src/analyzer/analyzer.ts +++ b/src/analyzer/analyzer.ts @@ -88,13 +88,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..c02c29b6b 100644 --- a/src/analyzer/extractor.ts +++ b/src/analyzer/extractor.ts @@ -45,7 +45,10 @@ export type ExtractSucceededResult = { resolveTemplateErrorMessage: undefined; }; -export type ExtractResult = ExtractTemplateResolveErrorResult | ExtractGraphQLErrorResult | ExtractSucceededResult; +export type ExtractFileResult = ExtractTemplateResolveErrorResult | ExtractGraphQLErrorResult | ExtractSucceededResult; +export type ExtractResult = { + fileEntries: ExtractFileResult[]; +}; export class Extractor { private readonly _removeDuplicatedFragments: boolean; @@ -58,8 +61,8 @@ export class Extractor { this._debug = debug; } - extract(files: string[], tagName?: string): ExtractResult[] { - const results: ExtractResult[] = []; + extract(files: string[], tagName?: string): ExtractResult { + const results: ExtractFileResult[] = []; this._debug('Extract template literals from: '); this._debug(files.map(f => ' ' + f).join(',\n')); files.forEach(fileName => { @@ -97,7 +100,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 +132,13 @@ export class Extractor { } } }); + return { + fileEntries, + }; } pickupErrors( - extractResults: ExtractResult[], + { fileEntries: extractResults }: ExtractResult, { ignoreGraphQLError }: { ignoreGraphQLError: boolean } = { ignoreGraphQLError: false }, ) { const errors: ErrorWithLocation[] = []; @@ -213,7 +219,7 @@ export class Extractor { return { type, operationName, fragmentName: noReferedFragmentNames[noReferedFragmentNames.length - 1] }; } - toManifest(extractResults: ExtractResult[], tagName: string = ''): ManifestOutput { + toManifest({ fileEntries: extractResults }: ExtractResult, tagName: string = ''): ManifestOutput { const documents = extractResults .filter(r => !!r.documentNode) .map(result => { 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/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..1a4b7a0a9 100644 --- a/src/analyzer/type-generator.ts +++ b/src/analyzer/type-generator.ts @@ -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,22 @@ 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 { 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 +80,30 @@ 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(fileEntry.documentNode, { 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); + 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 fbbd00581..f3c4ef233 100644 --- a/src/analyzer/validator.ts +++ b/src/analyzer/validator.ts @@ -4,7 +4,7 @@ import { ExtractResult } from './extractor'; import { ErrorWithLocation } from '../errors'; import { getFragmentsInDocument, getFragmentNamesInDocument } from '../gql-ast-util'; -export function validate(extractedResults: ExtractResult[], schema: GraphQLSchema) { +export function validate({ fileEntries: extractedResults }: ExtractResult, schema: GraphQLSchema) { const errors: ErrorWithLocation[] = []; const globalFragmentDefinitions = getFragmentsInDocument(...extractedResults.map(({ documentNode }) => documentNode)); extractedResults.forEach(r => { 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({ From 3a736fc4e38c4f74e14c04ae837569a18deece48 Mon Sep 17 00:00:00 2001 From: Quramy Date: Thu, 7 Mar 2024 22:39:42 +0900 Subject: [PATCH 09/41] refactor: Add throwErrorIfOutOfRange option to position converter --- src/string-util/position-converter.test.ts | 59 ++++++++++++++++++++++ src/string-util/position-converter.ts | 24 ++++++++- src/ts-ast-util/types.ts | 10 +++- 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/string-util/position-converter.test.ts b/src/string-util/position-converter.test.ts index bc56cccfa..b185be241 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,28 @@ describe('CRLF', () => { expect(pos2location(text, 5)).toStrictEqual({ line: 1, character: 0 }); }); }); + + describe('throwErrorIfOutOfRange', () => { + 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(); + }); + }); + }); }); diff --git a/src/string-util/position-converter.ts b/src/string-util/position-converter.ts index e7ce3dffe..39c4b1a28 100644 --- a/src/string-util/position-converter.ts +++ b/src/string-util/position-converter.ts @@ -1,4 +1,9 @@ -export function pos2location(content: string, pos: number) { +export function pos2location(content: string, pos: number, throwErrorIfOutOfRange = false) { + if (throwErrorIfOutOfRange) { + if (pos < 0 || content.length <= pos) { + throw new Error('OutOfRange'); + } + } let l = 0, c = 0; for (let i = 0; i < content.length && i < pos; i++) { @@ -13,12 +18,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 Error('OutOfRange'); + } + } 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 Error('OutOfRange'); + } if (ic === location.character) { return i; } @@ -30,5 +47,8 @@ export function location2pos(content: string, location: { line: number; characte ic++; } } + if (throwErrorIfOutOfRange) { + throw new Error('OutOfRange'); + } return content.length; } diff --git a/src/ts-ast-util/types.ts b/src/ts-ast-util/types.ts index 1b8224ff2..263c76f14 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 { From 6535b97efdffba84bc53d35ee3783d0066f604a8 Mon Sep 17 00:00:00 2001 From: Quramy Date: Thu, 7 Mar 2024 22:44:07 +0900 Subject: [PATCH 10/41] fix: Don't include errors in external fragments --- e2e/lang-server-specs/diagnostics-syntax.js | 5 +- .../get-semantic-diagonistics.test.ts | 74 ++++++++++++++++++- .../get-semantic-diagonistics.ts | 37 ++++++---- 3 files changed, 98 insertions(+), 18 deletions(-) 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/src/graphql-language-service-adapter/get-semantic-diagonistics.test.ts b/src/graphql-language-service-adapter/get-semantic-diagonistics.test.ts index 5442879b8..fde5495a9 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(); }); @@ -191,6 +202,67 @@ describe('getSemanticDiagnostics', () => { 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'); diff --git a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts index b819a6a5f..7a9d9f37b 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts @@ -1,5 +1,5 @@ import ts from 'typescript'; -import { getDiagnostics, type Diagnostic } from 'graphql-language-service'; +import { getDiagnostics, getFragmentDependencies, type Diagnostic } from 'graphql-language-service'; import { SchemaBuildErrorInfo } from '../schema-manager/schema-manager'; import { ERROR_CODES } from '../errors'; import { AnalysisContext, GetSemanticDiagnostics } from './types'; @@ -53,7 +53,12 @@ export function getSemanticDiagnostics(ctx: AnalysisContext, delegate: GetSemant } else if (schema) { const diagnosticsAndResolvedInfoList = nodes.map(n => { const { resolvedInfo, resolveErrors, fragmentNames } = ctx.resolveTemplateInfo(fileName, n); - const externalFragments = ctx.getGlobalFragmentDefinitions(fragmentNames); + + // TODO refactor + const globalFragments = ctx.getGlobalFragmentDefinitions(fragmentNames); + const map = new Map(globalFragments.map(def => [def.name.value, def])); + const externalFragments = resolvedInfo ? getFragmentDependencies(resolvedInfo.combinedText, map) : []; + return { resolveErrors, resolvedTemplateInfo: resolvedInfo, @@ -86,19 +91,23 @@ 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 { + return; } }); }); From 044f24a256b646a0998b9528aa8fb1f3da449048 Mon Sep 17 00:00:00 2001 From: Quramy Date: Thu, 7 Mar 2024 22:59:41 +0900 Subject: [PATCH 11/41] test: Modify position converter spec --- src/string-util/position-converter.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/string-util/position-converter.test.ts b/src/string-util/position-converter.test.ts index b185be241..26d1c3683 100644 --- a/src/string-util/position-converter.test.ts +++ b/src/string-util/position-converter.test.ts @@ -73,8 +73,19 @@ describe('CRLF', () => { }); 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\nefg'; + const text = 'abcd\r\nefg'; it.each([ { line: -1, character: 0 }, { line: 0, character: -1 }, From b70becf460465a45835c20393ec85c136d119a62 Mon Sep 17 00:00:00 2001 From: Quramy Date: Mon, 11 Mar 2024 09:20:19 +0900 Subject: [PATCH 12/41] refactor: Move getExternalFragments to FragmentRegistry --- src/gql-ast-util/fragment-registry.ts | 28 +++++++++++++++++-- .../get-semantic-diagonistics.ts | 11 ++------ .../graphql-language-service-adapter.ts | 7 +++-- src/graphql-language-service-adapter/types.ts | 8 ++---- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/gql-ast-util/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts index 23e7e56ad..83cc9f1e6 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -1,9 +1,13 @@ import { parse, type DocumentNode, FragmentDefinitionNode } from 'graphql'; -import { getFragmentsInDocument } from './utility-functions'; +import { getFragmentDependenciesForAST } from 'graphql-language-service'; +import { getFragmentsInDocument, getFragmentNamesInDocument } from './utility-functions'; + +type FileName = string; +type FileVersion = string; export class FragmentRegistry { - private _fileVersionMap = new Map(); - private _fragmentsMap = new Map(); + private _fileVersionMap = new Map(); + private _fragmentsMap = new Map(); getFileCurrentVersion(fileName: string): string | undefined { return this._fileVersionMap.get(fileName); @@ -13,6 +17,24 @@ export class FragmentRegistry { return [...this._fragmentsMap.values()].flat().filter(def => !fragmentNamesToBeIgnored.includes(def.name.value)); } + getExternalFragments(documentStr: string): FragmentDefinitionNode[] { + let docNode: DocumentNode | undefined = undefined; + try { + docNode = parse(documentStr); + } catch { + // Nothing to do + } + if (!docNode) return []; + const names = getFragmentNamesInDocument(docNode); + const map = new Map( + [...this._fragmentsMap.values()] + .flat() + .filter(def => !names.includes(def.name.value)) + .map(def => [def.name.value, def]), + ); + return getFragmentDependenciesForAST(docNode, map); + } + registerDocument(fileName: string, version: string, documentStrings: string[]): void { const definitions: FragmentDefinitionNode[] = []; for (const document of documentStrings) { diff --git a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts index 7a9d9f37b..f7e2d13a9 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts @@ -1,5 +1,5 @@ import ts from 'typescript'; -import { getDiagnostics, getFragmentDependencies, type Diagnostic } from 'graphql-language-service'; +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'; @@ -52,13 +52,8 @@ export function getSemanticDiagnostics(ctx: AnalysisContext, delegate: GetSemant }); } else if (schema) { const diagnosticsAndResolvedInfoList = nodes.map(n => { - const { resolvedInfo, resolveErrors, fragmentNames } = ctx.resolveTemplateInfo(fileName, n); - - // TODO refactor - const globalFragments = ctx.getGlobalFragmentDefinitions(fragmentNames); - const map = new Map(globalFragments.map(def => [def.name.value, def])); - const externalFragments = resolvedInfo ? getFragmentDependencies(resolvedInfo.combinedText, map) : []; - + const { resolvedInfo, resolveErrors } = ctx.resolveTemplateInfo(fileName, n); + const externalFragments = resolvedInfo ? ctx.getExternalFragmentDefinitions(resolvedInfo.combinedText) : []; return { resolveErrors, resolvedTemplateInfo: resolvedInfo, 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 0fde306ab..98aaa7213 100644 --- a/src/graphql-language-service-adapter/graphql-language-service-adapter.ts +++ b/src/graphql-language-service-adapter/graphql-language-service-adapter.ts @@ -76,6 +76,7 @@ export class GraphQLLanguageServiceAdapter { }, getGlobalFragmentDefinitions: fragmentNamesToBeIgnored => this._fragmentRegisry.getFragmentDefinitions(fragmentNamesToBeIgnored), + getExternalFragmentDefinitions: documentStr => this._fragmentRegisry.getExternalFragments(documentStr), findTemplateNode: (fileName, position) => this._findTemplateNode(fileName, position), findTemplateNodes: fileName => this._findTemplateNodes(fileName), resolveTemplateInfo: (fileName, node) => this._resolveTemplateInfo(fileName, node), @@ -119,7 +120,7 @@ export class GraphQLLanguageServiceAdapter { private _resolveTemplateInfo(fileName: string, node: ts.TemplateExpression | ts.NoSubstitutionTemplateLiteral) { const { resolvedInfo, resolveErrors } = this._helper.resolveTemplateLiteral(fileName, node); - if (!resolvedInfo) return { resolveErrors, fragmentNames: [] }; + if (!resolvedInfo) return { resolveErrors }; try { const documentNode = parse(resolvedInfo.combinedText); const fragmentNames = getFragmentNamesInDocument(documentNode); @@ -128,12 +129,12 @@ export class GraphQLLanguageServiceAdapter { const info = duplicatedFragmentInfoList.reduce((acc, fragmentInfo) => { return this._helper.updateTemplateLiteralInfo(acc, fragmentInfo); }, resolvedInfo); - return { resolvedInfo: info, resolveErrors, fragmentNames }; + return { resolvedInfo: info, resolveErrors }; } catch (error) { // 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, fragmentNames: [] }; + return { resolvedInfo, resolveErrors }; } } diff --git a/src/graphql-language-service-adapter/types.ts b/src/graphql-language-service-adapter/types.ts index f117bf07f..3bda760d1 100644 --- a/src/graphql-language-service-adapter/types.ts +++ b/src/graphql-language-service-adapter/types.ts @@ -13,15 +13,11 @@ export interface AnalysisContext { getSchema(): GraphQLSchema | null | undefined; getSchemaOrSchemaErrors(): [GraphQLSchema, null] | [null, SchemaBuildErrorInfo[]]; getGlobalFragmentDefinitions(fragmentNamesToBeIgnored?: string[]): FragmentDefinitionNode[]; + getExternalFragmentDefinitions(documentStr: string): FragmentDefinitionNode[]; findTemplateNode( fileName: string, position: number, ): ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression | undefined; findTemplateNodes(fileName: string): (ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[]; - resolveTemplateInfo( - fileName: string, - node: ts.TemplateExpression | ts.NoSubstitutionTemplateLiteral, - ): ResolveResult & { - fragmentNames: string[]; - }; + resolveTemplateInfo(fileName: string, node: ts.TemplateExpression | ts.NoSubstitutionTemplateLiteral): ResolveResult; } From 86519116bd087e5b6d48ce05a5eba60952ba988d Mon Sep 17 00:00:00 2001 From: Quramy Date: Mon, 11 Mar 2024 10:54:15 +0900 Subject: [PATCH 13/41] chore: Add LRU Cache class --- src/cache/index.ts | 1 + src/cache/lru-cache.test.ts | 45 +++++++++++++++++++++++++++++++++++++ src/cache/lru-cache.ts | 29 ++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 src/cache/index.ts create mode 100644 src/cache/lru-cache.test.ts create mode 100644 src/cache/lru-cache.ts 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); + } +} From 062190062f6a08c2f605a74c3518aa4869f1e3df Mon Sep 17 00:00:00 2001 From: Quramy Date: Mon, 11 Mar 2024 15:03:51 +0900 Subject: [PATCH 14/41] refactor: Cache for getExternalFragments --- src/gql-ast-util/fragment-registry.test.ts | 203 ++++++++++++++++++ src/gql-ast-util/fragment-registry.ts | 136 ++++++++++-- .../get-semantic-diagonistics.ts | 4 +- .../graphql-language-service-adapter.ts | 3 +- src/graphql-language-service-adapter/types.ts | 6 +- 5 files changed, 337 insertions(+), 15 deletions(-) create mode 100644 src/gql-ast-util/fragment-registry.test.ts 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..1300ad98c --- /dev/null +++ b/src/gql-ast-util/fragment-registry.test.ts @@ -0,0 +1,203 @@ +import { FragmentRegistry } from './fragment-registry'; + +describe(FragmentRegistry, () => { + describe(FragmentRegistry.prototype.getRegistrationHistory, () => { + it('should store added fragment names', () => { + const registry = new FragmentRegistry(); + registry.registerDocument('main.ts', '0', [ + ` + fragment FragmentA on Query { + __typename + } + `, + ]); + + const history = registry.getRegistrationHistory(); + expect(history.length).toBe(1); + expect(history[0].has('FragmentA')).toBeTruthy(); + }); + + it('should store changed fragment names', () => { + const registry = new FragmentRegistry(); + registry.registerDocument('main.ts', '0', [ + ` + fragment FragmentA on Query { + __typename + } + `, + ` + fragment FragmentB on Query { + __typename + } + `, + ]); + + registry.registerDocument('main.ts', '1', [ + ` + fragment FragmentA on Query { + __typename + } + `, + ` + fragment FragmentC on Query { + __typename + } + `, + ]); + + const history = registry.getRegistrationHistory(); + expect(history.length).toBe(2); + expect(history[1].has('FragmentA')).toBeFalsy(); + expect(history[1].has('FragmentB')).toBeTruthy(); + expect(history[1].has('FragmentC')).toBeTruthy(); + }); + + it('should store removed fragment names', () => { + const registry = new FragmentRegistry(); + registry.registerDocument('main.ts', '0', [ + ` + fragment FragmentA on Query { + __typename + } + `, + ]); + registry.removeDocument('main.ts'); + + const history = registry.getRegistrationHistory(); + expect(history.length).toBe(2); + expect(history[1].has('FragmentA')).toBeTruthy(); + }); + }); + + describe(FragmentRegistry.prototype.getExternalFragments, () => { + it('should return empty array when target document can not be parsed', () => { + const registry = new FragmentRegistry(); + registry.registerDocument('fragments.ts', '0', [ + ` + fragment FragmentA on Query { + __typename + } + `, + ]); + registry.registerDocument('main.ts', '0', ['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.registerDocument('fragments.ts', '0', [ + ` + fragment FragmentA on Query { + __typename + } + `, + ` + fragment FragmentX on Query { + __typename + } + `, + ]); + registry.registerDocument('main.ts', '0', ['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.registerDocument('fragments.ts', '0', [ + ` + fragment FragmentA on Query { + __typename + } + `, + ]); + + registry.registerDocument('main.ts', '0', ['fragment FragmentX on Query { ...FragmentA }']); + registry.getExternalFragments('fragment FragmentB on Query { ...FragmentA }', 'main.ts', 0); + expect(logger).toBeCalledTimes(0); + + registry.registerDocument('fragments.ts', '1', [ + ` + fragment FragmentA on Query { + __typename + id + } + `, + ]); + + const actual = registry.getExternalFragments('fragment FragmentB 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.registerDocument('fragments.ts', '0', [ + ` + fragment FragmentA on Query { + __typename + } + `, + ` + fragment FragmentB on Query { + __typename + } + `, + ]); + registry.registerDocument('main.ts', '0', ['fragment FragmentX on Query { ...FragmentA }']); + + registry.getExternalFragments('fragment FragmentB on Query { ...FragmentA }', 'main.ts', 0); + expect(logger).toBeCalledTimes(0); + + registry.registerDocument('main.ts', '1', ['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 use cached value', () => { + const logger = jest.fn(); + const registry = new FragmentRegistry({ logger }); + registry.registerDocument('fragments.ts', '0', [ + ` + fragment FragmentA on Query { + __typename + } + `, + ` + fragment FragmentB on Query { + __typename + } + `, + ]); + registry.registerDocument('main.ts', '0', ['fragment FragmentX on Query { ...FragmentA }']); + + registry.getExternalFragments('fragment FragmentB on Query { ...FragmentA }', 'main.ts', 0); + registry.registerDocument('main.ts', '1', ['fragment FragmentX on Query { ...FragmentA, __typename }']); + expect(logger).toBeCalledTimes(0); + + 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 index 83cc9f1e6..0a18b9b16 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -1,23 +1,64 @@ -import { parse, type DocumentNode, FragmentDefinitionNode } from 'graphql'; +import { parse, type DocumentNode, FragmentDefinitionNode, visit } from 'graphql'; import { getFragmentDependenciesForAST } from 'graphql-language-service'; +import { LRUCache } from '../cache'; import { getFragmentsInDocument, getFragmentNamesInDocument } from './utility-functions'; type FileName = string; type FileVersion = string; +type ExternalFragmentsCacheEntry = { + registryVersion: number; + externalFragments: FragmentDefinitionNode[]; + referencedFragmentNames: string[]; +}; + +type FragmentsMapEntry = { + text: string; + name: string; + node: FragmentDefinitionNode; +}; + +type FragmentRegistryCreateOptions = { + logger: (msg: string) => void; +}; + +function compareSet(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; +} + export class FragmentRegistry { + private _registryVersion = 0; + private _registrationHistroy: Set[] = []; private _fileVersionMap = new Map(); - private _fragmentsMap = new Map(); + private _fragmentsMap = new Map(); + private _externalFragmentsCache = new LRUCache(200); + private _logger: (msg: string) => void; + + constructor(options: FragmentRegistryCreateOptions = { logger: () => null }) { + this._logger = options.logger; + } getFileCurrentVersion(fileName: string): string | undefined { return this._fileVersionMap.get(fileName); } + getRegistrationHistory() { + return this._registrationHistroy; + } + getFragmentDefinitions(fragmentNamesToBeIgnored: string[] = []): FragmentDefinitionNode[] { - return [...this._fragmentsMap.values()].flat().filter(def => !fragmentNamesToBeIgnored.includes(def.name.value)); + return [...this._fragmentsMap.values()] + .flat() + .map(x => x.node) + .filter(def => !fragmentNamesToBeIgnored.includes(def.name.value)); } - getExternalFragments(documentStr: string): FragmentDefinitionNode[] { + getExternalFragments(documentStr: string, fileName: string, sourcePosition: number): FragmentDefinitionNode[] { let docNode: DocumentNode | undefined = undefined; try { docNode = parse(documentStr); @@ -25,34 +66,105 @@ export class FragmentRegistry { // Nothing to do } if (!docNode) return []; + const cacheKey = `${fileName}:${sourcePosition}`; + const cachedValue = this._externalFragmentsCache.get(cacheKey); + if (cachedValue) { + const changed = new Set( + this._registrationHistroy + .slice(cachedValue.registryVersion) + .reduce((acc, nameSet) => [...acc, ...nameSet.keys()], [] as string[]), + ); + let affectd = false; + const referencedFragmentNames = new Set(); + visit(docNode, { + FragmentSpread: node => { + affectd ||= changed.has(node.name.value); + referencedFragmentNames.add(node.name.value); + }, + }); + if (!affectd && compareSet(referencedFragmentNames, new Set(cachedValue.referencedFragmentNames))) { + this._logger('getExternalFragments: use cached value'); + return cachedValue.externalFragments; + } + } const names = getFragmentNamesInDocument(docNode); const map = new Map( [...this._fragmentsMap.values()] .flat() - .filter(def => !names.includes(def.name.value)) - .map(def => [def.name.value, def]), + .filter(({ name }) => !names.includes(name)) + .map(({ name, node }) => [name, node]), ); - return getFragmentDependenciesForAST(docNode, map); + const externalFragments = getFragmentDependenciesForAST(docNode, map); + const referencedFragmentNames: string[] = []; + visit(docNode, { + FragmentSpread: node => { + referencedFragmentNames.push(node.name.value); + }, + }); + this._externalFragmentsCache.set(cacheKey, { + registryVersion: this._registryVersion, + externalFragments, + referencedFragmentNames, + }); + return externalFragments; } registerDocument(fileName: string, version: string, documentStrings: string[]): void { - const definitions: FragmentDefinitionNode[] = []; - for (const document of documentStrings) { + const definitions: FragmentsMapEntry[] = []; + const previousValues = this._fragmentsMap.get(fileName) ?? []; + const changedFragmentNames = new Set(); + const previousFragmentNames = new Set(previousValues.map(({ name }) => name)); + const previousValuesMapByDocumentStr = new Map(); + previousValues.forEach(value => { + const arr = previousValuesMapByDocumentStr.get(value.text); + if (!arr) { + previousValuesMapByDocumentStr.set(value.text, [value]); + } else { + arr.push(value); + } + }); + for (const documentStr of documentStrings) { + const previous = previousValuesMapByDocumentStr.get(documentStr); + if (previous) { + previous.forEach(({ name }) => previousFragmentNames.delete(name)); + definitions.push(...previous); + continue; + } let docNode: DocumentNode | undefined = undefined; - this._fileVersionMap.set(fileName, version); try { - docNode = parse(document); + docNode = parse(documentStr); } catch {} if (!docNode) { continue; } - definitions.push(...getFragmentsInDocument(docNode)); + const newDefs = getFragmentsInDocument(docNode).map( + def => + ({ + text: documentStr, + name: def.name.value, + node: def, + }) satisfies FragmentsMapEntry, + ); + newDefs.forEach(({ name }) => changedFragmentNames.add(name)); + definitions.push(...newDefs); } + const affetctedFragmentNames = new Set([...previousFragmentNames.keys(), ...changedFragmentNames.keys()]); + this._fileVersionMap.set(fileName, version); this._fragmentsMap.set(fileName, definitions); + if (affetctedFragmentNames.size) { + this._registrationHistroy[this._registryVersion] = affetctedFragmentNames; + this._registryVersion++; + } } removeDocument(fileName: string): void { + const previousValues = this._fragmentsMap.get(fileName) ?? []; + const affetctedFragmentNames = new Set(previousValues.map(({ name }) => name)); this._fileVersionMap.delete(fileName); this._fragmentsMap.delete(fileName); + if (affetctedFragmentNames.size) { + this._registrationHistroy[this._registryVersion] = affetctedFragmentNames; + this._registryVersion++; + } } } diff --git a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts index f7e2d13a9..d4207c66b 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts @@ -53,7 +53,9 @@ export function getSemanticDiagnostics(ctx: AnalysisContext, delegate: GetSemant } else if (schema) { const diagnosticsAndResolvedInfoList = nodes.map(n => { const { resolvedInfo, resolveErrors } = ctx.resolveTemplateInfo(fileName, n); - const externalFragments = resolvedInfo ? ctx.getExternalFragmentDefinitions(resolvedInfo.combinedText) : []; + const externalFragments = resolvedInfo + ? ctx.getExternalFragmentDefinitions(resolvedInfo.combinedText, fileName, resolvedInfo.getSourcePosition(0).pos) + : []; return { resolveErrors, resolvedTemplateInfo: resolvedInfo, 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 98aaa7213..ef6dc4a68 100644 --- a/src/graphql-language-service-adapter/graphql-language-service-adapter.ts +++ b/src/graphql-language-service-adapter/graphql-language-service-adapter.ts @@ -76,7 +76,8 @@ export class GraphQLLanguageServiceAdapter { }, getGlobalFragmentDefinitions: fragmentNamesToBeIgnored => this._fragmentRegisry.getFragmentDefinitions(fragmentNamesToBeIgnored), - getExternalFragmentDefinitions: documentStr => this._fragmentRegisry.getExternalFragments(documentStr), + getExternalFragmentDefinitions: (documentStr, fileName, sourcePosition) => + this._fragmentRegisry.getExternalFragments(documentStr, fileName, sourcePosition), findTemplateNode: (fileName, position) => this._findTemplateNode(fileName, position), findTemplateNodes: fileName => this._findTemplateNodes(fileName), resolveTemplateInfo: (fileName, node) => this._resolveTemplateInfo(fileName, node), diff --git a/src/graphql-language-service-adapter/types.ts b/src/graphql-language-service-adapter/types.ts index 3bda760d1..9ec6105f9 100644 --- a/src/graphql-language-service-adapter/types.ts +++ b/src/graphql-language-service-adapter/types.ts @@ -13,7 +13,11 @@ export interface AnalysisContext { getSchema(): GraphQLSchema | null | undefined; getSchemaOrSchemaErrors(): [GraphQLSchema, null] | [null, SchemaBuildErrorInfo[]]; getGlobalFragmentDefinitions(fragmentNamesToBeIgnored?: string[]): FragmentDefinitionNode[]; - getExternalFragmentDefinitions(documentStr: string): FragmentDefinitionNode[]; + getExternalFragmentDefinitions( + documentStr: string, + fileName: string, + sourcePosition: number, + ): FragmentDefinitionNode[]; findTemplateNode( fileName: string, position: number, From 3d09a3f550eb5e40664673d8ce3823bfe5bc97b5 Mon Sep 17 00:00:00 2001 From: Quramy Date: Mon, 11 Mar 2024 15:45:20 +0900 Subject: [PATCH 15/41] test: Add e2e specs to cover onUpdate --- .../diagnostics-with-update.js | 69 +++++++++++++++++++ project-fixtures/simple-prj/fragments.ts | 0 2 files changed, 69 insertions(+) create mode 100644 e2e/lang-server-specs/diagnostics-with-update.js create mode 100644 project-fixtures/simple-prj/fragments.ts 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/simple-prj/fragments.ts b/project-fixtures/simple-prj/fragments.ts new file mode 100644 index 000000000..e69de29bb From ceaf364d0771d32248735fc5695dee29a60a6029 Mon Sep 17 00:00:00 2001 From: Quramy Date: Tue, 12 Mar 2024 02:10:00 +0900 Subject: [PATCH 16/41] chore: Set enabledGlobalFragments to fixture tsconfig --- project-fixtures/react-apollo-prj/tsconfig.json | 1 + project-fixtures/simple-prj/tsconfig.json | 1 + project-fixtures/typegen-addon-prj/tsconfig.json | 1 + 3 files changed, 3 insertions(+) diff --git a/project-fixtures/react-apollo-prj/tsconfig.json b/project-fixtures/react-apollo-prj/tsconfig.json index 755fcf874..12c1dc82c 100644 --- a/project-fixtures/react-apollo-prj/tsconfig.json +++ b/project-fixtures/react-apollo-prj/tsconfig.json @@ -11,6 +11,7 @@ { "name": "ts-graphql-plugin", "schema": "schema.graphql", + "enabledGlobalFragments": true, "tag": "gql", "typegen": { "addons": ["../../addons/typed-query-document"] 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"] } From e4e0df9d7d036e3c5fe53d5209e2bde39d8fff23 Mon Sep 17 00:00:00 2001 From: Quramy Date: Tue, 12 Mar 2024 02:10:45 +0900 Subject: [PATCH 17/41] refactor: FragmentRegistry caching --- src/analyzer/analyzer.test.ts | 1 + src/analyzer/analyzer.ts | 34 ++- src/analyzer/extractor.ts | 4 +- src/analyzer/markdown-reporter.test.ts | 18 +- src/analyzer/testing/testing-extractor.ts | 2 + src/gql-ast-util/fragment-registry.test.ts | 277 ++++++++++++------ src/gql-ast-util/fragment-registry.ts | 176 ++++++++--- src/gql-ast-util/utility-functions.test.ts | 2 +- .../testing/adapter-fixture.ts | 2 +- .../plugin-module-factory.ts | 121 ++++---- src/ts-ast-util/index.ts | 1 + .../register-document-change-event.ts | 12 +- src/ts-ast-util/utilily-functions.test.ts | 31 +- src/ts-ast-util/utilily-functions.ts | 24 +- src/types.ts | 1 + 15 files changed, 494 insertions(+), 212 deletions(-) rename src/{language-service-plugin => ts-ast-util}/register-document-change-event.ts (84%) diff --git a/src/analyzer/analyzer.test.ts b/src/analyzer/analyzer.test.ts index 2e871ec9c..90023faa6 100644 --- a/src/analyzer/analyzer.test.ts +++ b/src/analyzer/analyzer.test.ts @@ -20,6 +20,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', diff --git a/src/analyzer/analyzer.ts b/src/analyzer/analyzer.ts index f7ad4dc44..b143a4260 100644 --- a/src/analyzer/analyzer.ts +++ b/src/analyzer/analyzer.ts @@ -2,7 +2,8 @@ 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, isTagged, findAllNodes, registerDocumentChangeEvent } 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'; @@ -47,7 +48,9 @@ export class Analyzer { private readonly _schemaManager: SchemaManager, private readonly _debug: (msg: string) => void, ) { - const langService = ts.createLanguageService(this._languageServiceHost); + const documentRegistry = ts.createDocumentRegistry(); + const langService = ts.createLanguageService(this._languageServiceHost, documentRegistry); + const fragmentRegistry = new FragmentRegistry(); this._scriptSourceHelper = createScriptSourceHelper({ languageService: langService, languageServiceHost: this._languageServiceHost, @@ -55,6 +58,7 @@ export class Analyzer { this._extractor = new Extractor({ removeDuplicatedFragments: this._pluginConfig.removeDuplicatedFragments === false ? false : true, scriptSourceHelper: this._scriptSourceHelper, + fragmentRegistry, debug: this._debug, }); this._typeGenerator = new TypeGenerator({ @@ -64,6 +68,32 @@ 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 (this._languageServiceHost.getScriptFileNames().includes(fileName)) { + const templateLiteralNodes = findAllNodes(sourceFile, node => { + // TODO handle TemplateExpression + if (ts.isNoSubstitutionTemplateLiteral(node)) { + return true; + } + if (!tag) return true; + return !!isTagged(node, tag); + }) as ts.NoSubstitutionTemplateLiteral[]; + fragmentRegistry.registerDocument( + fileName, + version, + templateLiteralNodes.reduce( + (acc, node) => + node.rawText ? [...acc, { text: node.rawText, sourcePosition: node.getStart(sourceFile) }] : acc, + [] as { text: string; sourcePosition: number }[], + ), + ); + } + }, + }); + } } getPluginConfig() { diff --git a/src/analyzer/extractor.ts b/src/analyzer/extractor.ts index c02c29b6b..997ddde8d 100644 --- a/src/analyzer/extractor.ts +++ b/src/analyzer/extractor.ts @@ -4,11 +4,12 @@ import { visit } from 'graphql/language'; 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 } from '../gql-ast-util'; export type ExtractorOptions = { removeDuplicatedFragments: boolean; scriptSourceHelper: ScriptSourceHelper; + fragmentRegistry: FragmentRegistry; debug: (msg: string) => void; }; @@ -53,6 +54,7 @@ export type ExtractResult = { 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) { 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..f80659b96 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'; @@ -10,6 +11,7 @@ export function createTesintExtractor( const extractor = new Extractor({ removeDuplicatedFragments, scriptSourceHelper: createScriptSourceHelper({ languageService, languageServiceHost }), + fragmentRegistry: new FragmentRegistry(), debug: () => {}, }); return extractor; diff --git a/src/gql-ast-util/fragment-registry.test.ts b/src/gql-ast-util/fragment-registry.test.ts index 1300ad98c..761b0a38d 100644 --- a/src/gql-ast-util/fragment-registry.test.ts +++ b/src/gql-ast-util/fragment-registry.test.ts @@ -5,11 +5,14 @@ describe(FragmentRegistry, () => { it('should store added fragment names', () => { const registry = new FragmentRegistry(); registry.registerDocument('main.ts', '0', [ - ` - fragment FragmentA on Query { - __typename - } - `, + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, ]); const history = registry.getRegistrationHistory(); @@ -20,29 +23,41 @@ describe(FragmentRegistry, () => { it('should store changed fragment names', () => { const registry = new FragmentRegistry(); registry.registerDocument('main.ts', '0', [ - ` - fragment FragmentA on Query { - __typename - } - `, - ` - fragment FragmentB on Query { - __typename - } - `, + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, + { + sourcePosition: 0, + text: ` + fragment FragmentB on Query { + __typename + } + `, + }, ]); registry.registerDocument('main.ts', '1', [ - ` - fragment FragmentA on Query { - __typename - } - `, - ` - fragment FragmentC on Query { - __typename - } - `, + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, + { + sourcePosition: 0, + text: ` + fragment FragmentC on Query { + __typename + } + `, + }, ]); const history = registry.getRegistrationHistory(); @@ -55,11 +70,14 @@ describe(FragmentRegistry, () => { it('should store removed fragment names', () => { const registry = new FragmentRegistry(); registry.registerDocument('main.ts', '0', [ - ` - fragment FragmentA on Query { - __typename - } - `, + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, ]); registry.removeDocument('main.ts'); @@ -73,13 +91,16 @@ describe(FragmentRegistry, () => { it('should return empty array when target document can not be parsed', () => { const registry = new FragmentRegistry(); registry.registerDocument('fragments.ts', '0', [ - ` - fragment FragmentA on Query { - __typename - } - `, + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, ]); - registry.registerDocument('main.ts', '0', ['fragment X on Query {']); + registry.registerDocument('main.ts', '0', [{ sourcePosition: 0, text: 'fragment X on Query {' }]); expect(registry.getExternalFragments('fragment X on Query {', 'main.ts', 0)).toEqual([]); }); @@ -87,18 +108,26 @@ describe(FragmentRegistry, () => { it('should return dependent fragment definitions', () => { const registry = new FragmentRegistry(); registry.registerDocument('fragments.ts', '0', [ - ` - fragment FragmentA on Query { - __typename - } - `, - ` - fragment FragmentX on Query { - __typename - } - `, + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, + { + sourcePosition: 0, + text: ` + fragment FragmentX on Query { + __typename + } + `, + }, + ]); + registry.registerDocument('main.ts', '0', [ + { sourcePosition: 0, text: 'fragment FragmentB on Query { ...FragmentA }' }, ]); - registry.registerDocument('main.ts', '0', ['fragment FragmentB on Query { ...FragmentA }']); const actual = registry.getExternalFragments('fragment FragmentB on Query { ...FragmentA }', 'main.ts', 0); expect(actual.length).toBe(1); @@ -110,27 +139,36 @@ describe(FragmentRegistry, () => { const logger = jest.fn(); const registry = new FragmentRegistry({ logger }); registry.registerDocument('fragments.ts', '0', [ - ` - fragment FragmentA on Query { - __typename - } - `, + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, ]); - registry.registerDocument('main.ts', '0', ['fragment FragmentX on Query { ...FragmentA }']); - registry.getExternalFragments('fragment FragmentB on Query { ...FragmentA }', 'main.ts', 0); + registry.registerDocument('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.registerDocument('fragments.ts', '1', [ - ` - fragment FragmentA on Query { - __typename - id - } - `, + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + id + } + `, + }, ]); - const actual = registry.getExternalFragments('fragment FragmentB on Query { ...FragmentA }', 'main.ts', 0); + 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'); @@ -140,61 +178,130 @@ describe(FragmentRegistry, () => { const logger = jest.fn(); const registry = new FragmentRegistry({ logger }); registry.registerDocument('fragments.ts', '0', [ - ` - fragment FragmentA on Query { - __typename - } - `, - ` - fragment FragmentB on Query { - __typename - } - `, + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, + { + sourcePosition: 0, + text: ` + fragment FragmentB on Query { + __typename + } + `, + }, + ]); + registry.registerDocument('main.ts', '0', [ + { sourcePosition: 0, text: 'fragment FragmentX on Query { ...FragmentA }' }, ]); - registry.registerDocument('main.ts', '0', ['fragment FragmentX on Query { ...FragmentA }']); - registry.getExternalFragments('fragment FragmentB on Query { ...FragmentA }', 'main.ts', 0); + registry.getExternalFragments('fragment FragmentX on Query { ...FragmentA }', 'main.ts', 0); expect(logger).toBeCalledTimes(0); - registry.registerDocument('main.ts', '1', ['fragment FragmentX on Query { ...FragmentA, ...FragmentB }']); + registry.registerDocument('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(logger).toBeCalledTimes(0); expect(actual.length).toBe(2); }); - it('should use cached value', () => { + it('should not use cached value when FragmentDefinition set in target documentString changes', () => { const logger = jest.fn(); const registry = new FragmentRegistry({ logger }); registry.registerDocument('fragments.ts', '0', [ + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, + ]); + registry.registerDocument('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.registerDocument('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 } `, - ` - fragment FragmentB on Query { - __typename - } - `, + '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.registerDocument('fragments.ts', '0', [ + { + sourcePosition: 0, + text: ` + fragment FragmentA on Query { + __typename + } + `, + }, + { + sourcePosition: 0, + text: ` + fragment FragmentB on Query { + __typename + } + `, + }, + ]); + registry.registerDocument('main.ts', '0', [ + { sourcePosition: 0, text: 'fragment FragmentX on Query { ...FragmentA }' }, ]); - registry.registerDocument('main.ts', '0', ['fragment FragmentX on Query { ...FragmentA }']); - registry.getExternalFragments('fragment FragmentB on Query { ...FragmentA }', 'main.ts', 0); - registry.registerDocument('main.ts', '1', ['fragment FragmentX on Query { ...FragmentA, __typename }']); + registry.getExternalFragments('fragment FragmentX on Query { ...FragmentA }', 'main.ts', 0); expect(logger).toBeCalledTimes(0); + registry.registerDocument('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(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 index 0a18b9b16..f9b6e0cb2 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -6,16 +6,25 @@ import { getFragmentsInDocument, getFragmentNamesInDocument } from './utility-fu type FileName = string; type FileVersion = string; +type FragmentDefinitionEntry = { + fileName: string; + sourcePosition: number; + text: string; + name: string; + node: FragmentDefinitionNode; +}; + type ExternalFragmentsCacheEntry = { registryVersion: number; - externalFragments: FragmentDefinitionNode[]; + internalFragmentNames: string[]; referencedFragmentNames: string[]; + externalFragments: FragmentDefinitionNode[]; }; -type FragmentsMapEntry = { - text: string; - name: string; - node: FragmentDefinitionNode; +type UniqueDefinitionsResult = { + registryVersion: number; + validDefinitions: Map; + duplicatedDefinitions: Map; }; type FragmentRegistryCreateOptions = { @@ -35,8 +44,9 @@ export class FragmentRegistry { private _registryVersion = 0; private _registrationHistroy: Set[] = []; private _fileVersionMap = new Map(); - private _fragmentsMap = new Map(); + private _fragmentsMap = new Map(); private _externalFragmentsCache = new LRUCache(200); + private _uniqueDefinitionsCache = new LRUCache(200); private _logger: (msg: string) => void; constructor(options: FragmentRegistryCreateOptions = { logger: () => null }) { @@ -58,6 +68,51 @@ export class FragmentRegistry { .filter(def => !fragmentNamesToBeIgnored.includes(def.name.value)); } + getUniqueDefinitions(fragmentNamesToBeIgnored: string[] = []) { + if (fragmentNamesToBeIgnored.length === 0) { + return this._getWholeDefinitions(); + } + const cacheKey = fragmentNamesToBeIgnored.join(','); + const cachedValue = this._uniqueDefinitionsCache.get(cacheKey); + if (cachedValue) { + const changed = new Set( + this._registrationHistroy + .slice(cachedValue.registryVersion) + .reduce((acc, nameSet) => [...acc, ...nameSet.keys()], [] as string[]), + ); + const ignored = new Set(fragmentNamesToBeIgnored); + let notAffected = true; + for (const name of changed) { + notAffected &&= ignored.has(name); + } + if (notAffected) { + const { validDefinitions, duplicatedDefinitions } = cachedValue; + return { + validDefinitions, + duplicatedDefinitions, + }; + } + } + + const wholeDefinitions = this._getWholeDefinitions(); + const validDefinitions = new Map(wholeDefinitions.validDefinitions); + const duplicatedDefinitions = new Map(wholeDefinitions.duplicatedDefinitions); + for (const name of fragmentNamesToBeIgnored) { + validDefinitions.delete(name); + duplicatedDefinitions.delete(name); + } + const cacheEntry = { + registryVersion: this._registryVersion, + validDefinitions, + duplicatedDefinitions, + } satisfies UniqueDefinitionsResult; + this._uniqueDefinitionsCache.set(cacheKey, cacheEntry); + return { + validDefinitions, + duplicatedDefinitions, + }; + } + getExternalFragments(documentStr: string, fileName: string, sourcePosition: number): FragmentDefinitionNode[] { let docNode: DocumentNode | undefined = undefined; try { @@ -66,34 +121,37 @@ export class FragmentRegistry { // Nothing to do } if (!docNode) return []; + const names = getFragmentNamesInDocument(docNode); const cacheKey = `${fileName}:${sourcePosition}`; const cachedValue = this._externalFragmentsCache.get(cacheKey); if (cachedValue) { - const changed = new Set( - this._registrationHistroy - .slice(cachedValue.registryVersion) - .reduce((acc, nameSet) => [...acc, ...nameSet.keys()], [] as string[]), - ); - let affectd = false; - const referencedFragmentNames = new Set(); - visit(docNode, { - FragmentSpread: node => { - affectd ||= changed.has(node.name.value); - referencedFragmentNames.add(node.name.value); - }, - }); - if (!affectd && compareSet(referencedFragmentNames, new Set(cachedValue.referencedFragmentNames))) { - this._logger('getExternalFragments: use cached value'); - return cachedValue.externalFragments; + if (compareSet(new Set(cachedValue.internalFragmentNames), new Set(names))) { + const changed = new Set( + this._registrationHistroy + .slice(cachedValue.registryVersion) + .reduce((acc, nameSet) => [...acc, ...nameSet.keys()], [] as string[]), + ); + let affectd = false; + const referencedFragmentNames = new Set(); + visit(docNode, { + FragmentSpread: node => { + affectd ||= changed.has(node.name.value); + referencedFragmentNames.add(node.name.value); + }, + }); + if (!affectd && compareSet(referencedFragmentNames, new Set(cachedValue.referencedFragmentNames))) { + this._logger('getExternalFragments: use cached value'); + return cachedValue.externalFragments; + } } } - const names = getFragmentNamesInDocument(docNode); - const map = new Map( - [...this._fragmentsMap.values()] - .flat() - .filter(({ name }) => !names.includes(name)) - .map(({ name, node }) => [name, node]), - ); + // const map = new Map( + // [...this._fragmentsMap.values()] + // .flat() + // .filter(({ name }) => !names.includes(name)) + // .map(({ name, node }) => [name, node]), + // ); + const map = new Map([...this.getUniqueDefinitions(names).validDefinitions.entries()].map(([k, v]) => [k, v.node])); const externalFragments = getFragmentDependenciesForAST(docNode, map); const referencedFragmentNames: string[] = []; visit(docNode, { @@ -103,18 +161,23 @@ export class FragmentRegistry { }); this._externalFragmentsCache.set(cacheKey, { registryVersion: this._registryVersion, + internalFragmentNames: names, externalFragments, referencedFragmentNames, }); return externalFragments; } - registerDocument(fileName: string, version: string, documentStrings: string[]): void { - const definitions: FragmentsMapEntry[] = []; + registerDocument( + fileName: string, + version: string, + documentStrings: { text: string; sourcePosition: number }[], + ): void { + const definitions: FragmentDefinitionEntry[] = []; const previousValues = this._fragmentsMap.get(fileName) ?? []; const changedFragmentNames = new Set(); const previousFragmentNames = new Set(previousValues.map(({ name }) => name)); - const previousValuesMapByDocumentStr = new Map(); + const previousValuesMapByDocumentStr = new Map(); previousValues.forEach(value => { const arr = previousValuesMapByDocumentStr.get(value.text); if (!arr) { @@ -124,15 +187,15 @@ export class FragmentRegistry { } }); for (const documentStr of documentStrings) { - const previous = previousValuesMapByDocumentStr.get(documentStr); + const previous = previousValuesMapByDocumentStr.get(documentStr.text); if (previous) { previous.forEach(({ name }) => previousFragmentNames.delete(name)); - definitions.push(...previous); + definitions.push(...previous.map(v => ({ ...v, sourcePosition: documentStr.sourcePosition }))); continue; } let docNode: DocumentNode | undefined = undefined; try { - docNode = parse(documentStr); + docNode = parse(documentStr.text); } catch {} if (!docNode) { continue; @@ -140,10 +203,12 @@ export class FragmentRegistry { const newDefs = getFragmentsInDocument(docNode).map( def => ({ - text: documentStr, + fileName, + sourcePosition: documentStr.sourcePosition, + text: documentStr.text, name: def.name.value, node: def, - }) satisfies FragmentsMapEntry, + }) satisfies FragmentDefinitionEntry, ); newDefs.forEach(({ name }) => changedFragmentNames.add(name)); definitions.push(...newDefs); @@ -167,4 +232,41 @@ export class FragmentRegistry { this._registryVersion++; } } + + private _getWholeDefinitions() { + const cached = this._uniqueDefinitionsCache.get(''); + if (cached && cached.registryVersion === this._registryVersion) { + const { validDefinitions, duplicatedDefinitions } = cached; + return { validDefinitions, duplicatedDefinitions }; + } + const map = new Map(); + const duplicatedNames = new Set(); + for (const list of this._fragmentsMap.values()) { + for (const v of list) { + const hit = map.get(v.name); + if (!hit) { + map.set(v.name, [v]); + } else { + hit.push(v); + duplicatedNames.add(v.name); + } + } + } + const duplicatedDefinitions = new Map(); + for (const name of duplicatedNames) { + duplicatedDefinitions.set(name, map.get(name)!); + map.delete(name); + } + const validDefinitions = new Map([...map.entries()].map(([k, v]) => [k, v[0]])); + const cacheEntry = { + registryVersion: this._registryVersion, + validDefinitions, + duplicatedDefinitions, + } satisfies UniqueDefinitionsResult; + this._uniqueDefinitionsCache.set('', cacheEntry); + return { + validDefinitions, + duplicatedDefinitions, + }; + } } diff --git a/src/gql-ast-util/utility-functions.test.ts b/src/gql-ast-util/utility-functions.test.ts index d97f22604..4d3b049c3 100644 --- a/src/gql-ast-util/utility-functions.test.ts +++ b/src/gql-ast-util/utility-functions.test.ts @@ -1,5 +1,5 @@ -import { detectDuplicatedFragments } from './utility-functions'; import { parse } from 'graphql'; +import { detectDuplicatedFragments } from './utility-functions'; describe(detectDuplicatedFragments, () => { it('should detect duplicated fragments info', () => { diff --git a/src/graphql-language-service-adapter/testing/adapter-fixture.ts b/src/graphql-language-service-adapter/testing/adapter-fixture.ts index af03c9d53..b2dafc49a 100644 --- a/src/graphql-language-service-adapter/testing/adapter-fixture.ts +++ b/src/graphql-language-service-adapter/testing/adapter-fixture.ts @@ -42,6 +42,6 @@ export class AdapterFixture { } addFragment(fragmentDefDoc: string, sourceFileName = this._sourceFileName) { - this._fragmentRegistry.registerDocument(sourceFileName, 'v1', [fragmentDefDoc]); + this._fragmentRegistry.registerDocument(sourceFileName, 'v1', [{ sourcePosition: 0, text: fragmentDefDoc }]); } } diff --git a/src/language-service-plugin/plugin-module-factory.ts b/src/language-service-plugin/plugin-module-factory.ts index 7b9733ead..3c88595df 100644 --- a/src/language-service-plugin/plugin-module-factory.ts +++ b/src/language-service-plugin/plugin-module-factory.ts @@ -3,8 +3,13 @@ import { TsGraphQLPluginConfigOptions } from '../types'; import { GraphQLLanguageServiceAdapter } from '../graphql-language-service-adapter'; import { SchemaManagerFactory, createSchemaManagerHostFromLSPluginInfo } from '../schema-manager'; import { FragmentRegistry } from '../gql-ast-util'; -import { createScriptSourceHelper, isTagged, findAllNodes } from '../ts-ast-util'; -import { registerDocumentChangeEvent } from './register-document-change-event'; +import { + createScriptSourceHelper, + registerDocumentChangeEvent, + hasTagged, + findAllNodes, + getShallowText, +} from '../ts-ast-util'; import { LanguageServiceProxyBuilder } from './language-service-proxy-builder'; function create(info: ts.server.PluginCreateInfo): ts.LanguageService { @@ -15,65 +20,67 @@ 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 enabledGlobalFragments = config.enabledGlobalFragments === true; - const fragmentRegistry = new FragmentRegistry(); - registerDocumentChangeEvent( - // Note: - // documentRegistry in ts.server.Project is annotated @internal - (info.project as any).documentRegistry as ts.DocumentRegistry, - { - onAcquire: (fileName, sourceFile, version) => { - if (info.languageServiceHost.getScriptFileNames().includes(fileName)) { - // TODO remove before merge - logger('acquire script ' + fileName + version); + const fragmentRegistry = new FragmentRegistry({ logger }); + if (enabledGlobalFragments) { + registerDocumentChangeEvent( + // Note: + // documentRegistry in ts.server.Project is annotated @internal + (info.project as any).documentRegistry as ts.DocumentRegistry, + { + onAcquire: (fileName, sourceFile, version) => { + if (info.languageServiceHost.getScriptFileNames().includes(fileName)) { + // TODO remove before merge + logger('acquire script ' + fileName + version); - const templateLiteralNodes = findAllNodes(sourceFile, node => { - // TODO handle TemplateExpression - if (ts.isNoSubstitutionTemplateLiteral(node)) { - return true; - } - if (!tag) return true; - return !!isTagged(node, tag); - }) as ts.NoSubstitutionTemplateLiteral[]; - fragmentRegistry.registerDocument( - fileName, - version, - templateLiteralNodes.reduce( - (docs, node) => (node.rawText ? [...docs, node.rawText] : docs), - [] as string[], - ), - ); - } - }, - onUpdate: (fileName, sourceFile, version) => { - if (info.languageServiceHost.getScriptFileNames().includes(fileName)) { - if (fragmentRegistry.getFileCurrentVersion(fileName) === version) return; - // TODO remove before merge - logger('update script ' + fileName + version); + const templateLiteralNodes = findAllNodes(sourceFile, node => { + if (tag && ts.isTaggedTemplateExpression(node) && hasTagged(node, tag, sourceFile)) { + return true; + } else { + return ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node); + } + }) as (ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[]; + logger('templateLiteralNodes: ' + templateLiteralNodes.length); + fragmentRegistry.registerDocument( + fileName, + version, + templateLiteralNodes.reduce( + (acc, node) => [...acc, getShallowText(node)], + [] as { text: string; sourcePosition: number }[], + ), + ); + } + }, + onUpdate: (fileName, sourceFile, version) => { + if (info.languageServiceHost.getScriptFileNames().includes(fileName)) { + if (fragmentRegistry.getFileCurrentVersion(fileName) === version) return; + // TODO remove before merge + logger('update script ' + fileName + version); - const templateLiteralNodes = findAllNodes(sourceFile, node => { - // TODO handle TemplateExpression - if (ts.isNoSubstitutionTemplateLiteral(node)) { - return true; - } - if (!tag) return true; - return !!isTagged(node, tag); - }) as ts.NoSubstitutionTemplateLiteral[]; - fragmentRegistry.registerDocument( - fileName, - version, - templateLiteralNodes.reduce( - (docs, node) => (node.rawText ? [...docs, node.rawText] : docs), - [] as string[], - ), - ); - } - }, - onRelease: fileName => { - fragmentRegistry.removeDocument(fileName); + const templateLiteralNodes = findAllNodes(sourceFile, node => { + if (tag && ts.isTaggedTemplateExpression(node) && hasTagged(node, tag, sourceFile)) { + return true; + } else { + return ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node); + } + }) as (ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[]; + fragmentRegistry.registerDocument( + fileName, + version, + templateLiteralNodes.reduce( + (acc, node) => [...acc, getShallowText(node)], + [] as { text: string; sourcePosition: number }[], + ), + ); + } + }, + onRelease: fileName => { + fragmentRegistry.removeDocument(fileName); + }, }, - }, - ); + ); + } const scriptSourceHelper = createScriptSourceHelper(info); const adapter = new GraphQLLanguageServiceAdapter(scriptSourceHelper, { diff --git a/src/ts-ast-util/index.ts b/src/ts-ast-util/index.ts index 301ddb253..24c2f302e 100644 --- a/src/ts-ast-util/index.ts +++ b/src/ts-ast-util/index.ts @@ -2,6 +2,7 @@ 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'; diff --git a/src/language-service-plugin/register-document-change-event.ts b/src/ts-ast-util/register-document-change-event.ts similarity index 84% rename from src/language-service-plugin/register-document-change-event.ts rename to src/ts-ast-util/register-document-change-event.ts index 88b478b3d..08e780121 100644 --- a/src/language-service-plugin/register-document-change-event.ts +++ b/src/ts-ast-util/register-document-change-event.ts @@ -2,8 +2,8 @@ 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; + onUpdate?: (fileName: string, sourceFile: ts.SourceFile, version: string) => void; + onRelease?: (fileName: string) => void; }; export function registerDocumentChangeEvent( @@ -32,7 +32,7 @@ export function registerDocumentChangeEvent( apply: (delegate, thisArg, args: Parameters) => { const [fileName, , , version] = args; const sourceFile = delegate.apply(thisArg, args); - documentChangeEventListener.onUpdate(fileName, sourceFile, version); + documentChangeEventListener.onUpdate?.(fileName, sourceFile, version); return sourceFile; }, }); @@ -41,7 +41,7 @@ export function registerDocumentChangeEvent( apply: (delegate, thisArg, args: Parameters) => { const [fileName, , , , , version] = args; const sourceFile = delegate.apply(thisArg, args); - documentChangeEventListener.onUpdate(fileName, sourceFile, version); + documentChangeEventListener.onUpdate?.(fileName, sourceFile, version); return sourceFile; }, }); @@ -50,7 +50,7 @@ export function registerDocumentChangeEvent( apply: (delegate, thisArg, args: Parameters) => { const [fileName] = args; delegate.apply(thisArg, args); - documentChangeEventListener.onRelease(fileName); + documentChangeEventListener.onRelease?.(fileName); }, }); @@ -58,7 +58,7 @@ export function registerDocumentChangeEvent( apply: (delegate, thisArg, args: Parameters) => { const [fileName] = args; delegate.apply(thisArg, args); - documentChangeEventListener.onRelease(fileName); + documentChangeEventListener.onRelease?.(fileName); }, }); } diff --git a/src/ts-ast-util/utilily-functions.test.ts b/src/ts-ast-util/utilily-functions.test.ts index 14ffad70b..2191a3254 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, + getShallowText, 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,30 @@ describe(findAllNodes, () => { }); }); +describe(getShallowText, () => { + 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)) as ( + | ts.NoSubstitutionTemplateLiteral + | ts.TemplateExpression + )[]; + const actual = getShallowText(found); + expect(actual.text).toBe('abc'); + }); + + it('should retun replaced text for TemplateExpression', () => { + const text = 'const a = `abc${hoge}`;'; + const s = ts.createSourceFile('input.ts', text, ts.ScriptTarget.Latest, true); + const [found] = findAllNodes(s, node => ts.isTemplateExpression(node)) as ( + | ts.NoSubstitutionTemplateLiteral + | ts.TemplateExpression + )[]; + const actual = getShallowText(found); + expect(actual.text).toBe('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..5326d4eb1 100644 --- a/src/ts-ast-util/utilily-functions.ts +++ b/src/ts-ast-util/utilily-functions.ts @@ -66,16 +66,32 @@ 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 getShallowText( + node: ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression, +) { + const n = ts.isTaggedTemplateExpression(node) ? node.template : node; + if (ts.isNoSubstitutionTemplateLiteral(n)) { + return { text: n.text ?? '', sourcePosition: n.pos }; + } else { + let text = n.head.text ?? ''; + for (const span of n.templateSpans) { + text += ''.padEnd(span.expression.end - span.expression.pos + 3, ' '); + text += span.literal.text; + } + return { text, sourcePosition: n.pos }; + } } export function isTemplateLiteralTypeNode(node: ts.Node): node is ts.TemplateLiteralTypeNode { diff --git a/src/types.ts b/src/types.ts index abe2e58a9..95d38c89c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ import { SchemaConfig } from './schema-manager'; export type TsGraphQLPluginConfigOptions = SchemaConfig & { name: string; + enabledGlobalFragments?: boolean; removeDuplicatedFragments?: boolean; tag?: string; typegen?: { From 78899348afd86afad0d102846ac27a784a5395a1 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 02:59:03 +0900 Subject: [PATCH 18/41] refactor: Extract DefinitionFileStore --- src/gql-ast-util/fragment-registry.test.ts | 259 +++++++---- src/gql-ast-util/fragment-registry.ts | 489 +++++++++++++-------- 2 files changed, 477 insertions(+), 271 deletions(-) diff --git a/src/gql-ast-util/fragment-registry.test.ts b/src/gql-ast-util/fragment-registry.test.ts index 761b0a38d..2d22a0300 100644 --- a/src/gql-ast-util/fragment-registry.test.ts +++ b/src/gql-ast-util/fragment-registry.test.ts @@ -1,92 +1,197 @@ -import { FragmentRegistry } from './fragment-registry'; +import { FragmentRegistry, DefinitionFileStore } from './fragment-registry'; -describe(FragmentRegistry, () => { - describe(FragmentRegistry.prototype.getRegistrationHistory, () => { - it('should store added fragment names', () => { - const registry = new FragmentRegistry(); - registry.registerDocument('main.ts', '0', [ - { - sourcePosition: 0, - text: ` - fragment FragmentA on Query { - __typename - } - `, - }, - ]); +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; + }; - const history = registry.getRegistrationHistory(); - expect(history.length).toBe(1); - expect(history[0].has('FragmentA')).toBeTruthy(); + describe(DefinitionFileStore.prototype.updateDocuments, () => { + it('shouild not change nothing given empty array', () => { + const store = createTestingStore(); + store.update('main.ts', []); + expect(store.getStoreVersion()).toBe(0); }); - it('should store changed fragment names', () => { - const registry = new FragmentRegistry(); - registry.registerDocument('main.ts', '0', [ - { - sourcePosition: 0, - text: ` - fragment FragmentA on Query { - __typename - } - `, - }, - { - sourcePosition: 0, - text: ` - fragment FragmentB on Query { - __typename - } - `, - }, - ]); + describe('first regstration', () => { + describe('when given single text', () => { + const store = createTestingStore(); + beforeEach(() => { + store.update('main.ts', ['A']); + }); - registry.registerDocument('main.ts', '1', [ - { - sourcePosition: 0, - text: ` - fragment FragmentA on Query { - __typename - } - `, - }, - { - sourcePosition: 0, - text: ` - fragment FragmentC on Query { - __typename - } - `, - }, - ]); + 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']); + }); - const history = registry.getRegistrationHistory(); - expect(history.length).toBe(2); - expect(history[1].has('FragmentA')).toBeFalsy(); - expect(history[1].has('FragmentB')).toBeTruthy(); - expect(history[1].has('FragmentC')).toBeTruthy(); + test('correct duplicated definition', () => { + expect([...store.getDuplicatedDefinitonMap().keys()]).toMatchObject(['A']); + }); + }); }); - it('should store removed fragment names', () => { - const registry = new FragmentRegistry(); - registry.registerDocument('main.ts', '0', [ - { - sourcePosition: 0, - text: ` - fragment FragmentA on Query { - __typename - } - `, - }, - ]); - registry.removeDocument('main.ts'); + describe('updating', () => { + describe.each` + docs | updated | appeared | disappeared | unique | duplicated + ${['A:0']} | ${[]} | ${[]} | ${[]} | ${['A']} | ${[]} + ${['A:1']} | ${['A']} | ${[]} | ${[]} | ${['A']} | ${[]} + ${[]} | ${[]} | ${[]} | ${['A']} | ${[]} | ${[]} + ${['B:0']} | ${[]} | ${['B']} | ${['A']} | ${['B']} | ${[]} + ${['A:1', 'A:2']} | ${[]} | ${[]} | ${['A']} | ${[]} | ${['A']} + `('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` + 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); + }); - const history = registry.getRegistrationHistory(); - expect(history.length).toBe(2); - expect(history[1].has('FragmentA')).toBeTruthy(); + 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(); diff --git a/src/gql-ast-util/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts index f9b6e0cb2..b49e4fcc6 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -3,35 +3,28 @@ import { getFragmentDependenciesForAST } from 'graphql-language-service'; import { LRUCache } from '../cache'; import { getFragmentsInDocument, getFragmentNamesInDocument } from './utility-functions'; -type FileName = string; -type FileVersion = string; - -type FragmentDefinitionEntry = { - fileName: string; - sourcePosition: number; - text: string; - name: string; - node: FragmentDefinitionNode; -}; - -type ExternalFragmentsCacheEntry = { - registryVersion: number; - internalFragmentNames: string[]; - referencedFragmentNames: string[]; - externalFragments: FragmentDefinitionNode[]; -}; - -type UniqueDefinitionsResult = { - registryVersion: number; - validDefinitions: Map; - duplicatedDefinitions: Map; -}; +function union(...sets: Set[]) { + return new Set(sets.map(s => [...s.values()]).flat()); +} -type FragmentRegistryCreateOptions = { - logger: (msg: string) => void; -}; +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 compareSet(a: Set, b: Set) { +function compare(a: Set, b: Set) { if (a.size !== b.size) return false; let result = true; for (const key of a.keys()) { @@ -40,127 +33,321 @@ function compareSet(a: Set, b: Set) { return result; } -export class FragmentRegistry { - private _registryVersion = 0; - private _registrationHistroy: Set[] = []; - private _fileVersionMap = new Map(); - private _fragmentsMap = new Map(); - private _externalFragmentsCache = new LRUCache(200); - private _uniqueDefinitionsCache = new LRUCache(200); - private _logger: (msg: string) => void; +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; +} - constructor(options: FragmentRegistryCreateOptions = { logger: () => null }) { - this._logger = options.logger; +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; } - getFileCurrentVersion(fileName: string): string | undefined { - return this._fileVersionMap.get(fileName); + 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'); + } } - getRegistrationHistory() { - return this._registrationHistroy; + getStoreVersion() { + return this._storeVersion; } - getFragmentDefinitions(fragmentNamesToBeIgnored: string[] = []): FragmentDefinitionNode[] { - return [...this._fragmentsMap.values()] - .flat() - .map(x => x.node) - .filter(def => !fragmentNamesToBeIgnored.includes(def.name.value)); + getDetailedAffectedDefinitions(from: number) { + return this._affectedDefinitonNameHistories.slice(from); } - getUniqueDefinitions(fragmentNamesToBeIgnored: string[] = []) { - if (fragmentNamesToBeIgnored.length === 0) { - return this._getWholeDefinitions(); + 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.forEach(v => { + v.extra = doc.extra; + valuesNotChanged.push(v); + }); + 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 cacheKey = fragmentNamesToBeIgnored.join(','); - const cachedValue = this._uniqueDefinitionsCache.get(cacheKey); - if (cachedValue) { - const changed = new Set( - this._registrationHistroy - .slice(cachedValue.registryVersion) - .reduce((acc, nameSet) => [...acc, ...nameSet.keys()], [] as string[]), - ); - const ignored = new Set(fragmentNamesToBeIgnored); - let notAffected = true; - for (const name of changed) { - notAffected &&= ignored.has(name); + + 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; } - if (notAffected) { - const { validDefinitions, duplicatedDefinitions } = cachedValue; - return { - validDefinitions, - duplicatedDefinitions, - }; + } + } + + 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); + 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()]); } } - const wholeDefinitions = this._getWholeDefinitions(); - const validDefinitions = new Map(wholeDefinitions.validDefinitions); - const duplicatedDefinitions = new Map(wholeDefinitions.duplicatedDefinitions); - for (const name of fragmentNamesToBeIgnored) { - validDefinitions.delete(name); - duplicatedDefinitions.delete(name); + for (const name of namesFromDuplicatedToUnique.values()) { + if (!this._uniqueRecordMap.has(name)) { + namesFromDuplicatedToUnique.delete(name); + } } - const cacheEntry = { - registryVersion: this._registryVersion, - validDefinitions, - duplicatedDefinitions, - } satisfies UniqueDefinitionsResult; - this._uniqueDefinitionsCache.set(cacheKey, cacheEntry); + return { - validDefinitions, - duplicatedDefinitions, + 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(200); + private _logger: (msg: string) => void; + + constructor(options: FragmentRegistryCreateOptions = { logger: () => null }) { + this._logger = options.logger; + } + + getFileCurrentVersion(fileName: string) { + return this._fileVersionMap.get(fileName); + } + + getFragmentDefinitions(fragmentNamesToBeIgnored: string[] = []): FragmentDefinitionNode[] { + return [...this._store.getUniqueDefinitonMap().entries()] + .filter(([name]) => !fragmentNamesToBeIgnored.includes(name)) + .map(([, v]) => v.node); + } getExternalFragments(documentStr: string, fileName: string, sourcePosition: number): FragmentDefinitionNode[] { - let docNode: DocumentNode | undefined = undefined; + let documentNode: DocumentNode | undefined = undefined; try { - docNode = parse(documentStr); + documentNode = parse(documentStr); } catch { // Nothing to do } - if (!docNode) return []; - const names = getFragmentNamesInDocument(docNode); + if (!documentNode) return []; + const names = getFragmentNamesInDocument(documentNode); const cacheKey = `${fileName}:${sourcePosition}`; const cachedValue = this._externalFragmentsCache.get(cacheKey); if (cachedValue) { - if (compareSet(new Set(cachedValue.internalFragmentNames), new Set(names))) { - const changed = new Set( - this._registrationHistroy - .slice(cachedValue.registryVersion) - .reduce((acc, nameSet) => [...acc, ...nameSet.keys()], [] as string[]), - ); + if (compare(new Set(cachedValue.internalFragmentNames), new Set(names))) { + const changed = this._store.getSummarizedAffectedDefinitions(cachedValue.storeVersion); let affectd = false; const referencedFragmentNames = new Set(); - visit(docNode, { + visit(documentNode, { FragmentSpread: node => { affectd ||= changed.has(node.name.value); referencedFragmentNames.add(node.name.value); }, }); - if (!affectd && compareSet(referencedFragmentNames, new Set(cachedValue.referencedFragmentNames))) { + if (!affectd && compare(referencedFragmentNames, new Set(cachedValue.referencedFragmentNames))) { this._logger('getExternalFragments: use cached value'); return cachedValue.externalFragments; } } } - // const map = new Map( - // [...this._fragmentsMap.values()] - // .flat() - // .filter(({ name }) => !names.includes(name)) - // .map(({ name, node }) => [name, node]), - // ); - const map = new Map([...this.getUniqueDefinitions(names).validDefinitions.entries()].map(([k, v]) => [k, v.node])); - const externalFragments = getFragmentDependenciesForAST(docNode, map); + 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(docNode, { + visit(documentNode, { FragmentSpread: node => { referencedFragmentNames.push(node.name.value); }, }); this._externalFragmentsCache.set(cacheKey, { - registryVersion: this._registryVersion, + storeVersion: this._store.getStoreVersion(), internalFragmentNames: names, externalFragments, referencedFragmentNames, @@ -173,100 +360,14 @@ export class FragmentRegistry { version: string, documentStrings: { text: string; sourcePosition: number }[], ): void { - const definitions: FragmentDefinitionEntry[] = []; - const previousValues = this._fragmentsMap.get(fileName) ?? []; - const changedFragmentNames = new Set(); - const previousFragmentNames = new Set(previousValues.map(({ name }) => name)); - const previousValuesMapByDocumentStr = new Map(); - previousValues.forEach(value => { - const arr = previousValuesMapByDocumentStr.get(value.text); - if (!arr) { - previousValuesMapByDocumentStr.set(value.text, [value]); - } else { - arr.push(value); - } - }); - for (const documentStr of documentStrings) { - const previous = previousValuesMapByDocumentStr.get(documentStr.text); - if (previous) { - previous.forEach(({ name }) => previousFragmentNames.delete(name)); - definitions.push(...previous.map(v => ({ ...v, sourcePosition: documentStr.sourcePosition }))); - continue; - } - let docNode: DocumentNode | undefined = undefined; - try { - docNode = parse(documentStr.text); - } catch {} - if (!docNode) { - continue; - } - const newDefs = getFragmentsInDocument(docNode).map( - def => - ({ - fileName, - sourcePosition: documentStr.sourcePosition, - text: documentStr.text, - name: def.name.value, - node: def, - }) satisfies FragmentDefinitionEntry, - ); - newDefs.forEach(({ name }) => changedFragmentNames.add(name)); - definitions.push(...newDefs); - } - const affetctedFragmentNames = new Set([...previousFragmentNames.keys(), ...changedFragmentNames.keys()]); this._fileVersionMap.set(fileName, version); - this._fragmentsMap.set(fileName, definitions); - if (affetctedFragmentNames.size) { - this._registrationHistroy[this._registryVersion] = affetctedFragmentNames; - this._registryVersion++; - } + this._store.updateDocuments( + fileName, + documentStrings.map(({ text, sourcePosition }) => ({ text, extra: { sourcePosition } })), + ); } removeDocument(fileName: string): void { - const previousValues = this._fragmentsMap.get(fileName) ?? []; - const affetctedFragmentNames = new Set(previousValues.map(({ name }) => name)); - this._fileVersionMap.delete(fileName); - this._fragmentsMap.delete(fileName); - if (affetctedFragmentNames.size) { - this._registrationHistroy[this._registryVersion] = affetctedFragmentNames; - this._registryVersion++; - } - } - - private _getWholeDefinitions() { - const cached = this._uniqueDefinitionsCache.get(''); - if (cached && cached.registryVersion === this._registryVersion) { - const { validDefinitions, duplicatedDefinitions } = cached; - return { validDefinitions, duplicatedDefinitions }; - } - const map = new Map(); - const duplicatedNames = new Set(); - for (const list of this._fragmentsMap.values()) { - for (const v of list) { - const hit = map.get(v.name); - if (!hit) { - map.set(v.name, [v]); - } else { - hit.push(v); - duplicatedNames.add(v.name); - } - } - } - const duplicatedDefinitions = new Map(); - for (const name of duplicatedNames) { - duplicatedDefinitions.set(name, map.get(name)!); - map.delete(name); - } - const validDefinitions = new Map([...map.entries()].map(([k, v]) => [k, v[0]])); - const cacheEntry = { - registryVersion: this._registryVersion, - validDefinitions, - duplicatedDefinitions, - } satisfies UniqueDefinitionsResult; - this._uniqueDefinitionsCache.set('', cacheEntry); - return { - validDefinitions, - duplicatedDefinitions, - }; + this._store.updateDocuments(fileName, []); } } From 485e4eddf34cfa1779f4b5b8d76c708a46163c6b Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 07:24:48 +0900 Subject: [PATCH 19/41] fix: Stop to emit unneeded disappeare record of DefinitionFileStore --- src/gql-ast-util/fragment-registry.test.ts | 60 +++++++++++++++++++++- src/gql-ast-util/fragment-registry.ts | 4 +- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/gql-ast-util/fragment-registry.test.ts b/src/gql-ast-util/fragment-registry.test.ts index 2d22a0300..b622774fa 100644 --- a/src/gql-ast-util/fragment-registry.test.ts +++ b/src/gql-ast-util/fragment-registry.test.ts @@ -130,11 +130,11 @@ describe(DefinitionFileStore, () => { describe('updating', () => { describe.each` docs | updated | appeared | disappeared | unique | duplicated + ${[]} | ${[]} | ${[]} | ${['A']} | ${[]} | ${[]} ${['A:0']} | ${[]} | ${[]} | ${[]} | ${['A']} | ${[]} ${['A:1']} | ${['A']} | ${[]} | ${[]} | ${['A']} | ${[]} - ${[]} | ${[]} | ${[]} | ${['A']} | ${[]} | ${[]} - ${['B:0']} | ${[]} | ${['B']} | ${['A']} | ${['B']} | ${[]} ${['A:1', 'A:2']} | ${[]} | ${[]} | ${['A']} | ${[]} | ${['A']} + ${['B:0']} | ${[]} | ${['B']} | ${['A']} | ${['B']} | ${[]} `('file1: ["A:0"] -> file1: $docs', ({ docs, ...expected }) => { let store: TestingStore; beforeEach(() => { @@ -158,6 +158,62 @@ describe(DefinitionFileStore, () => { }); }); + 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.each` file1Docs | file2Docs | affected | unique | duplicated ${['A:0', 'B:0']} | ${['A:1']} | ${['B', 'A']} | ${['B']} | ${['A']} diff --git a/src/gql-ast-util/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts index b49e4fcc6..5fe427f9a 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -230,7 +230,9 @@ export class DefinitionFileStore { const alreadyStoredValueAsUnique = this._uniqueRecordMap.get(name); if (alreadyStoredValueAsUnique) { this._uniqueRecordMap.delete(name); - namesRemoved.add(name); + if (!namesFromDuplicatedToUnique.has(name)) { + namesRemoved.add(name); + } continue; } From d1628f625bf3dfecfc3c214c12dcd93abfe4c94b Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 08:09:39 +0900 Subject: [PATCH 20/41] fix: Don't push multiple items whose text are same to valuesNotChanged list --- src/gql-ast-util/fragment-registry.test.ts | 24 ++++++++++++++++++++++ src/gql-ast-util/fragment-registry.ts | 8 +++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/gql-ast-util/fragment-registry.test.ts b/src/gql-ast-util/fragment-registry.test.ts index b622774fa..6372d64d9 100644 --- a/src/gql-ast-util/fragment-registry.test.ts +++ b/src/gql-ast-util/fragment-registry.test.ts @@ -214,6 +214,30 @@ describe(DefinitionFileStore, () => { }); }); + 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']} diff --git a/src/gql-ast-util/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts index 5fe427f9a..64926b3b5 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -135,11 +135,9 @@ export class DefinitionFileStore { for (const doc of documents) { const alreadyParsedItems = currentValueMapByText.get(doc.text); - if (alreadyParsedItems) { - alreadyParsedItems.forEach(v => { - v.extra = doc.extra; - valuesNotChanged.push(v); - }); + if (alreadyParsedItems && alreadyParsedItems.length === 1) { + alreadyParsedItems[0].extra = doc.extra; + valuesNotChanged.push(alreadyParsedItems[0]); currentValueMapByText.delete(doc.text); continue; } From 371955cb52062e03f90dc346ece3014bc003aba2 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 13:00:10 +0900 Subject: [PATCH 21/41] chore: Remove unneeded logger calling --- src/language-service-plugin/plugin-module-factory.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/language-service-plugin/plugin-module-factory.ts b/src/language-service-plugin/plugin-module-factory.ts index 3c88595df..50c999e6c 100644 --- a/src/language-service-plugin/plugin-module-factory.ts +++ b/src/language-service-plugin/plugin-module-factory.ts @@ -41,7 +41,6 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { return ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node); } }) as (ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[]; - logger('templateLiteralNodes: ' + templateLiteralNodes.length); fragmentRegistry.registerDocument( fileName, version, From 877fbba85cfb1814dff327a50896f52ecb833221 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 13:00:31 +0900 Subject: [PATCH 22/41] chore: Remove unused optional arguments --- .../graphql-language-service-adapter.ts | 3 +-- src/graphql-language-service-adapter/types.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) 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 ef6dc4a68..e1b81ecff 100644 --- a/src/graphql-language-service-adapter/graphql-language-service-adapter.ts +++ b/src/graphql-language-service-adapter/graphql-language-service-adapter.ts @@ -74,8 +74,7 @@ export class GraphQLLanguageServiceAdapter { return [this._schema, null]; } }, - getGlobalFragmentDefinitions: fragmentNamesToBeIgnored => - this._fragmentRegisry.getFragmentDefinitions(fragmentNamesToBeIgnored), + getGlobalFragmentDefinitions: () => this._fragmentRegisry.getFragmentDefinitions(), getExternalFragmentDefinitions: (documentStr, fileName, sourcePosition) => this._fragmentRegisry.getExternalFragments(documentStr, fileName, sourcePosition), findTemplateNode: (fileName, position) => this._findTemplateNode(fileName, position), diff --git a/src/graphql-language-service-adapter/types.ts b/src/graphql-language-service-adapter/types.ts index 9ec6105f9..86fc868da 100644 --- a/src/graphql-language-service-adapter/types.ts +++ b/src/graphql-language-service-adapter/types.ts @@ -12,7 +12,7 @@ export interface AnalysisContext { getScriptSourceHelper(): ScriptSourceHelper; getSchema(): GraphQLSchema | null | undefined; getSchemaOrSchemaErrors(): [GraphQLSchema, null] | [null, SchemaBuildErrorInfo[]]; - getGlobalFragmentDefinitions(fragmentNamesToBeIgnored?: string[]): FragmentDefinitionNode[]; + getGlobalFragmentDefinitions(): FragmentDefinitionNode[]; getExternalFragmentDefinitions( documentStr: string, fileName: string, From 68ab48e675db0afd5ba94ba55d7eec94c46ebb2f Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 13:01:32 +0900 Subject: [PATCH 23/41] feat: Validate command using external fragments --- .../__snapshots__/analyzer.test.ts.snap | 2 ++ src/analyzer/analyzer.test.ts | 25 +++++++++++++++++++ src/analyzer/analyzer.ts | 20 +++++++++------ src/analyzer/extractor.ts | 20 ++++++++++++--- src/analyzer/validator.ts | 21 ++++++++-------- src/gql-ast-util/fragment-registry.ts | 15 ++++++++--- src/gql-ast-util/utility-functions.ts | 8 ++++++ 7 files changed, 85 insertions(+), 26 deletions(-) diff --git a/src/analyzer/__snapshots__/analyzer.test.ts.snap b/src/analyzer/__snapshots__/analyzer.test.ts.snap index 648d617c3..e01a939a9 100644 --- a/src/analyzer/__snapshots__/analyzer.test.ts.snap +++ b/src/analyzer/__snapshots__/analyzer.test.ts.snap @@ -105,3 +105,5 @@ exports[`Analyzer typegen should report error when no schema 1`] = `"No GraphQL 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 validate project with schema error project 1`] = `"Syntax Error: Unexpected Name "hogehoge"."`; + +exports[`Analyzer validate should validate project with semantic warning project 1`] = `"Unknown fragment "F2"."`; diff --git a/src/analyzer/analyzer.test.ts b/src/analyzer/analyzer.test.ts index 90023faa6..5706c24a1 100644 --- a/src/analyzer/analyzer.test.ts +++ b/src/analyzer/analyzer.test.ts @@ -153,6 +153,23 @@ 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 }\`; + `, + }, + ], +}; + describe(Analyzer, () => { describe(Analyzer.prototype.extractToManifest, () => { it('should extract manifest', () => { @@ -198,6 +215,14 @@ describe(Analyzer, () => { expect(schema).toBeTruthy(); }); + it('should validate project with semantic warning project', 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 work with fragments in template expression', async () => { const analyzer = createTestingAnalyzer(fragmentExpressionPrj); const { errors } = await analyzer.validate(); diff --git a/src/analyzer/analyzer.ts b/src/analyzer/analyzer.ts index b143a4260..8f9dc00f3 100644 --- a/src/analyzer/analyzer.ts +++ b/src/analyzer/analyzer.ts @@ -2,7 +2,13 @@ import ts from 'typescript'; import path from 'path'; import { ScriptSourceHelper } from '../ts-ast-util/types'; import { Extractor } from './extractor'; -import { createScriptSourceHelper, isTagged, findAllNodes, registerDocumentChangeEvent } from '../ts-ast-util'; +import { + createScriptSourceHelper, + hasTagged, + findAllNodes, + registerDocumentChangeEvent, + getShallowText, +} from '../ts-ast-util'; import { FragmentRegistry } from '../gql-ast-util'; import { SchemaManager, SchemaBuildErrorInfo } from '../schema-manager/schema-manager'; import { TsGqlError, ErrorWithLocation, ErrorWithoutLocation } from '../errors'; @@ -74,19 +80,17 @@ export class Analyzer { onAcquire: (fileName, sourceFile, version) => { if (this._languageServiceHost.getScriptFileNames().includes(fileName)) { const templateLiteralNodes = findAllNodes(sourceFile, node => { - // TODO handle TemplateExpression - if (ts.isNoSubstitutionTemplateLiteral(node)) { + if (tag && ts.isTaggedTemplateExpression(node) && hasTagged(node, tag, sourceFile)) { return true; + } else { + return ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node); } - if (!tag) return true; - return !!isTagged(node, tag); - }) as ts.NoSubstitutionTemplateLiteral[]; + }) as (ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[]; fragmentRegistry.registerDocument( fileName, version, templateLiteralNodes.reduce( - (acc, node) => - node.rawText ? [...acc, { text: node.rawText, sourcePosition: node.getStart(sourceFile) }] : acc, + (acc, node) => [...acc, getShallowText(node)], [] as { text: string; sourcePosition: number }[], ), ); diff --git a/src/analyzer/extractor.ts b/src/analyzer/extractor.ts index 997ddde8d..5cf8b29eb 100644 --- a/src/analyzer/extractor.ts +++ b/src/analyzer/extractor.ts @@ -1,5 +1,5 @@ import ts from 'typescript'; -import { parse, print, DocumentNode, GraphQLError } from 'graphql'; +import { parse, print, GraphQLError, type DocumentNode, type FragmentDefinitionNode } from 'graphql'; import { visit } from 'graphql/language'; import { isTagged, ScriptSourceHelper, ResolvedTemplateInfo } from '../ts-ast-util'; import { ManifestOutput, ManifestDocumentEntry, OperationType } from './types'; @@ -47,18 +47,25 @@ export type ExtractSucceededResult = { }; export type ExtractFileResult = ExtractTemplateResolveErrorResult | ExtractGraphQLErrorResult | ExtractSucceededResult; + export type ExtractResult = { fileEntries: ExtractFileResult[]; + globalFragments: { + definitions: FragmentDefinitionNode[]; + definitionMap: Map; + // TODO additional entries from FragmentRegistry + }; }; export class Extractor { private readonly _removeDuplicatedFragments: boolean; private readonly _helper: ScriptSourceHelper; - // private readonly _fragmentRegistry: FragmentRegistry; + 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; } @@ -134,8 +141,15 @@ export class Extractor { } } }); + + const globalDefinitonsWithMap = this._fragmentRegistry.getFragmentDefinitionsWithMap(); + return { fileEntries, + globalFragments: { + definitions: globalDefinitonsWithMap.definitions, + definitionMap: globalDefinitonsWithMap.map, + }, }; } diff --git a/src/analyzer/validator.ts b/src/analyzer/validator.ts index f3c4ef233..b144c491f 100644 --- a/src/analyzer/validator.ts +++ b/src/analyzer/validator.ts @@ -1,23 +1,22 @@ import { GraphQLSchema } from 'graphql'; -import { getDiagnostics } from 'graphql-language-service'; +import { getDiagnostics, getFragmentDependenciesForAST } from 'graphql-language-service'; import { ExtractResult } from './extractor'; import { ErrorWithLocation } from '../errors'; -import { getFragmentsInDocument, getFragmentNamesInDocument } from '../gql-ast-util'; +import { getFragmentNamesInDocument, cloneFragmentMap } from '../gql-ast-util'; -export function validate({ fileEntries: extractedResults }: ExtractResult, schema: GraphQLSchema) { +export function validate({ fileEntries: extractedResults, globalFragments }: ExtractResult, schema: GraphQLSchema) { const errors: ErrorWithLocation[] = []; - const globalFragmentDefinitions = getFragmentsInDocument(...extractedResults.map(({ documentNode }) => documentNode)); extractedResults.forEach(r => { if (!r.resolevedTemplateInfo) return; const { combinedText, getSourcePosition, convertInnerLocation2InnerPosition } = r.resolevedTemplateInfo; const fragmentNamesInText = getFragmentNamesInDocument(r.documentNode); - const diagnostics = getDiagnostics( - combinedText, - schema, - undefined, - undefined, - globalFragmentDefinitions.filter(def => !fragmentNamesInText.includes(def.name.value)), - ); + 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), diff --git a/src/gql-ast-util/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts index 64926b3b5..6e7e08413 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -300,10 +300,17 @@ export class FragmentRegistry { return this._fileVersionMap.get(fileName); } - getFragmentDefinitions(fragmentNamesToBeIgnored: string[] = []): FragmentDefinitionNode[] { - return [...this._store.getUniqueDefinitonMap().entries()] - .filter(([name]) => !fragmentNamesToBeIgnored.includes(name)) - .map(([, v]) => v.node); + 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, + }; } getExternalFragments(documentStr: string, fileName: string, sourcePosition: number): FragmentDefinitionNode[] { diff --git a/src/gql-ast-util/utility-functions.ts b/src/gql-ast-util/utility-functions.ts index 4336da333..052278c22 100644 --- a/src/gql-ast-util/utility-functions.ts +++ b/src/gql-ast-util/utility-functions.ts @@ -26,6 +26,14 @@ export function getFragmentNamesInDocument(...documentNodes: (DocumentNode | und 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[] = []; From 3f6502be04efedb113a57b570f5d882b7fed6cb4 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 13:43:02 +0900 Subject: [PATCH 24/41] feat: Make typegen command use global fragments --- .../__snapshots__/analyzer.test.ts.snap | 12 ++++++- src/analyzer/analyzer.test.ts | 8 +++-- src/analyzer/type-generator.ts | 32 +++++++++++++++++-- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/analyzer/__snapshots__/analyzer.test.ts.snap b/src/analyzer/__snapshots__/analyzer.test.ts.snap index e01a939a9..09248db90 100644 --- a/src/analyzer/__snapshots__/analyzer.test.ts.snap +++ b/src/analyzer/__snapshots__/analyzer.test.ts.snap @@ -93,10 +93,20 @@ 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; +}; " `; diff --git a/src/analyzer/analyzer.test.ts b/src/analyzer/analyzer.test.ts index 5706c24a1..8b49de771 100644 --- a/src/analyzer/analyzer.test.ts +++ b/src/analyzer/analyzer.test.ts @@ -269,12 +269,14 @@ describe(Analyzer, () => { }); it('should create type files', async () => { - const analyzer = createTestingAnalyzer(simpleSources); + const analyzer = createTestingAnalyzer(externalFragmentsPrj); 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/type-generator.ts b/src/analyzer/type-generator.ts index 1a4b7a0a9..c7e7cd3a0 100644 --- a/src/analyzer/type-generator.ts +++ b/src/analyzer/type-generator.ts @@ -1,11 +1,13 @@ import path from 'path'; import ts from 'typescript'; -import { GraphQLSchema } from 'graphql/type'; +import { GraphQLSchema, visit, type DocumentNode, type ASTNode, Kind } from 'graphql'; +import { getFragmentDependenciesForAST } from 'graphql-language-service'; import { TsGqlError, ErrorWithLocation } from '../errors'; import { mergeAddons, TypeGenVisitor, TypeGenError, TypeGenAddonFactory, TypeGenVisitorAddonContext } from '../typegen'; import { dasherize } from '../string-util'; import { OutputSource, createOutputSource } from '../ts-ast-util'; +import { cloneFragmentMap, getFragmentNamesInDocument } from '../gql-ast-util'; import { Extractor, ExtractSucceededResult } from './extractor'; export type TypeGeneratorOptions = { @@ -66,6 +68,31 @@ export class TypeGenerator { const outputSourceFiles: { fileName: string; content: string }[] = []; extractedResult.fileEntries.forEach(fileEntry => { if (fileEntry.documentNode) { + const externalFragments = getFragmentDependenciesForAST( + fileEntry.documentNode, + cloneFragmentMap( + extractedResult.globalFragments.definitionMap, + getFragmentNamesInDocument(fileEntry.documentNode), + ), + ); + const targetDocumentNode: 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; + }; const { type, fragmentName, operationName } = this._extractor.getDominantDefinition(fileEntry); if (type === 'complex') { const fileName = fileEntry.fileName; @@ -87,7 +114,7 @@ export class TypeGenerator { try { const outputSource = createOutputSource({ outputFileName }); const { addon } = this.createAddon({ schema, outputSource, fileEntry }); - const outputSourceFile = visitor.visit(fileEntry.documentNode, { outputSource, addon }); + const outputSourceFile = visitor.visit(targetDocumentNode, { outputSource, addon }); const content = this._printer.printFile(outputSourceFile); outputSourceFiles.push({ fileName: outputFileName, content }); this._debug( @@ -98,6 +125,7 @@ export class TypeGenerator { ); } catch (error) { if (error instanceof TypeGenError) { + if (isDefinedExternal(error.node)) return; const sourcePosition = fileEntry.resolevedTemplateInfo.getSourcePosition(error.node.loc!.start); if (sourcePosition.isInOtherExpression) return; const fileName = fileEntry.fileName; From 63565a0ed2c83783818a30623faec43431d6ce98 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 15:22:16 +0900 Subject: [PATCH 25/41] feat: Make report command work with global fragments --- .../__snapshots__/analyzer.test.ts.snap | 22 ++----- src/analyzer/analyzer.test.ts | 7 --- src/analyzer/extractor.ts | 59 +++++++++++++++++-- src/analyzer/type-generator.ts | 32 +--------- 4 files changed, 61 insertions(+), 59 deletions(-) diff --git a/src/analyzer/__snapshots__/analyzer.test.ts.snap b/src/analyzer/__snapshots__/analyzer.test.ts.snap index 09248db90..b5092b836 100644 --- a/src/analyzer/__snapshots__/analyzer.test.ts.snap +++ b/src/analyzer/__snapshots__/analyzer.test.ts.snap @@ -44,24 +44,10 @@ exports[`Analyzer report should create markdown report 1`] = ` \`\`\`graphql query MyQuery { - hello + ...MyFragment } -\`\`\` - -From [main.ts:1:19](main.ts#L1-L1) - ---- -Extracted by [ts-graphql-plugin](https://github.com/Quramy/ts-graphql-plugin)" -`; - -exports[`Analyzer report should create markdown report from manifest 1`] = ` -"# Extracted GraphQL Operations -## Queries - -### MyQuery -\`\`\`graphql -query MyQuery { +fragment MyFragment on Query { hello } \`\`\` @@ -72,7 +58,7 @@ From [main.ts:1:19](main.ts#L1-L1) Extracted by [ts-graphql-plugin](https://github.com/Quramy/ts-graphql-plugin)" `; -exports[`Analyzer report should create markdown report with external fragments 1`] = ` +exports[`Analyzer report should create markdown report from manifest 1`] = ` "# Extracted GraphQL Operations ## Queries @@ -80,7 +66,7 @@ exports[`Analyzer report should create markdown report with external fragments 1 \`\`\`graphql query MyQuery { - ...MyFragment + hello } \`\`\` diff --git a/src/analyzer/analyzer.test.ts b/src/analyzer/analyzer.test.ts index 8b49de771..62eb09238 100644 --- a/src/analyzer/analyzer.test.ts +++ b/src/analyzer/analyzer.test.ts @@ -238,13 +238,6 @@ describe(Analyzer, () => { describe(Analyzer.prototype.report, () => { it('should create markdown report', () => { - const analyzer = createTestingAnalyzer(simpleSources); - const [errors, output] = analyzer.report('out.md'); - expect(errors.length).toBe(0); - expect(output).toMatchSnapshot(); - }); - - it('should create markdown report with external fragments', () => { const analyzer = createTestingAnalyzer(externalFragmentsPrj); const [errors, output] = analyzer.report('out.md'); expect(errors.length).toBe(0); diff --git a/src/analyzer/extractor.ts b/src/analyzer/extractor.ts index 5cf8b29eb..23b87e0f1 100644 --- a/src/analyzer/extractor.ts +++ b/src/analyzer/extractor.ts @@ -1,10 +1,24 @@ import ts from 'typescript'; -import { parse, print, GraphQLError, type DocumentNode, type FragmentDefinitionNode } 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, FragmentRegistry } from '../gql-ast-util'; +import { + detectDuplicatedFragments, + FragmentRegistry, + getFragmentNamesInDocument, + cloneFragmentMap, +} from '../gql-ast-util'; export type ExtractorOptions = { removeDuplicatedFragments: boolean; @@ -235,7 +249,42 @@ export class Extractor { return { type, operationName, fragmentName: noReferedFragmentNames[noReferedFragmentNames.length - 1] }; } - toManifest({ fileEntries: 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 => { @@ -246,7 +295,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/type-generator.ts b/src/analyzer/type-generator.ts index c7e7cd3a0..ed1c3f414 100644 --- a/src/analyzer/type-generator.ts +++ b/src/analyzer/type-generator.ts @@ -1,13 +1,11 @@ import path from 'path'; import ts from 'typescript'; -import { GraphQLSchema, visit, type DocumentNode, type ASTNode, Kind } from 'graphql'; -import { getFragmentDependenciesForAST } from 'graphql-language-service'; +import { GraphQLSchema } from 'graphql'; import { TsGqlError, ErrorWithLocation } from '../errors'; import { mergeAddons, TypeGenVisitor, TypeGenError, TypeGenAddonFactory, TypeGenVisitorAddonContext } from '../typegen'; import { dasherize } from '../string-util'; import { OutputSource, createOutputSource } from '../ts-ast-util'; -import { cloneFragmentMap, getFragmentNamesInDocument } from '../gql-ast-util'; import { Extractor, ExtractSucceededResult } from './extractor'; export type TypeGeneratorOptions = { @@ -68,31 +66,7 @@ export class TypeGenerator { const outputSourceFiles: { fileName: string; content: string }[] = []; extractedResult.fileEntries.forEach(fileEntry => { if (fileEntry.documentNode) { - const externalFragments = getFragmentDependenciesForAST( - fileEntry.documentNode, - cloneFragmentMap( - extractedResult.globalFragments.definitionMap, - getFragmentNamesInDocument(fileEntry.documentNode), - ), - ); - const targetDocumentNode: 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; - }; + const { inflatedDocumentNode, isDefinedExternal } = this._extractor.inflateDocument(fileEntry, extractedResult); const { type, fragmentName, operationName } = this._extractor.getDominantDefinition(fileEntry); if (type === 'complex') { const fileName = fileEntry.fileName; @@ -114,7 +88,7 @@ export class TypeGenerator { try { const outputSource = createOutputSource({ outputFileName }); const { addon } = this.createAddon({ schema, outputSource, fileEntry }); - const outputSourceFile = visitor.visit(targetDocumentNode, { outputSource, addon }); + const outputSourceFile = visitor.visit(inflatedDocumentNode, { outputSource, addon }); const content = this._printer.printFile(outputSourceFile); outputSourceFiles.push({ fileName: outputFileName, content }); this._debug( From f8923d7637d8acc2cf1ef516553deae27d48000f Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 16:04:17 +0900 Subject: [PATCH 26/41] test: Refactor analyezr specs --- .../__snapshots__/analyzer.test.ts.snap | 34 +++++++++++++++++-- src/analyzer/analyzer.test.ts | 21 ++++++------ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/analyzer/__snapshots__/analyzer.test.ts.snap b/src/analyzer/__snapshots__/analyzer.test.ts.snap index b5092b836..3a09dab0c 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": { diff --git a/src/analyzer/analyzer.test.ts b/src/analyzer/analyzer.test.ts index 62eb09238..6c2ee9daf 100644 --- a/src/analyzer/analyzer.test.ts +++ b/src/analyzer/analyzer.test.ts @@ -56,7 +56,7 @@ const simpleSources = { ], }; -const externalFragmentsPrj = { +const fragmentPrj = { sdl: ` type Query { hello: String! @@ -173,7 +173,7 @@ const externalFragmentErrorPrj = { describe(Analyzer, () => { describe(Analyzer.prototype.extractToManifest, () => { it('should extract manifest', () => { - const analyzer = createTestingAnalyzer(simpleSources); + const analyzer = createTestingAnalyzer(fragmentPrj); expect(analyzer.extractToManifest()).toMatchSnapshot(); }); }); @@ -186,6 +186,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(); @@ -228,17 +235,11 @@ describe(Analyzer, () => { const { errors } = await analyzer.validate(); expect(errors.length).toBe(0); }); - - it('should work with external fragments', async () => { - const analyzer = createTestingAnalyzer(externalFragmentsPrj); - const { errors } = await analyzer.validate(); - expect(errors.length).toBe(0); - }); }); describe(Analyzer.prototype.report, () => { it('should create markdown report', () => { - const analyzer = createTestingAnalyzer(externalFragmentsPrj); + const analyzer = createTestingAnalyzer(fragmentPrj); const [errors, output] = analyzer.report('out.md'); expect(errors.length).toBe(0); expect(output).toMatchSnapshot(); @@ -262,7 +263,7 @@ describe(Analyzer, () => { }); it('should create type files', async () => { - const analyzer = createTestingAnalyzer(externalFragmentsPrj); + const analyzer = createTestingAnalyzer(fragmentPrj); const { outputSourceFiles } = await analyzer.typegen(); if (!outputSourceFiles) return fail(); expect(outputSourceFiles.length).toBe(2); From ecbaa14d1ea1c3759e9924d2e4898a347466596b Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 16:56:42 +0900 Subject: [PATCH 27/41] chore: Update example --- .../react-apollo-prj/GRAPHQL_OPERATIONS.md | 12 ++++++------ .../src/__generated__/git-hub-query.ts | 6 +++--- project-fixtures/react-apollo-prj/src/index.tsx | 1 - 3 files changed, 9 insertions(+), 10 deletions(-) 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/src/__generated__/git-hub-query.ts b/project-fixtures/react-apollo-prj/src/__generated__/git-hub-query.ts index 2fc8bff25..c14c0d021 100644 --- a/project-fixtures/react-apollo-prj/src/__generated__/git-hub-query.ts +++ b/project-fixtures/react-apollo-prj/src/__generated__/git-hub-query.ts @@ -1,9 +1,6 @@ /* eslint-disable */ /* This is an autogenerated file. Do not edit this file directly! */ import { TypedQueryDocumentNode } from "graphql"; -export type RepositoryFragment = { - description: string | null; -}; export type GitHubQuery = { viewer: { repositories: { @@ -17,3 +14,6 @@ export type GitHubQueryVariables = { first: number; }; export type GitHubQueryDocument = TypedQueryDocumentNode; +export type RepositoryFragment = { + description: string | null; +}; diff --git a/project-fixtures/react-apollo-prj/src/index.tsx b/project-fixtures/react-apollo-prj/src/index.tsx index 9a2879b08..ad9787dfd 100644 --- a/project-fixtures/react-apollo-prj/src/index.tsx +++ b/project-fixtures/react-apollo-prj/src/index.tsx @@ -9,7 +9,6 @@ const repositoryFragment = gql` `; const query = gql` - ${repositoryFragment} query GitHubQuery($first: Int!) { viewer { repositories(first: $first) { From 3fac3e6f4f883fa72e3e47d7f9f81dd31425aef8 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 17:55:19 +0900 Subject: [PATCH 28/41] test: Add registerDocumentChangeEvent test --- .../register-document-change-event.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/ts-ast-util/register-document-change-event.test.ts 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'); + }); +}); From 7fbbbd402dd7bf7483810542c2a53e5091287e12 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 21:57:40 +0900 Subject: [PATCH 29/41] feat: Add exclude option --- src/analyzer/analyzer.ts | 19 +++- src/analyzer/extractor.ts | 1 + src/analyzer/testing/testing-extractor.ts | 9 +- .../get-completion-at-position.ts | 1 + .../get-quick-info-at-position.ts | 1 + .../get-semantic-diagonistics.ts | 1 + .../testing/adapter-fixture.ts | 5 +- .../plugin-module-factory.ts | 8 +- src/ts-ast-util/file-name-filter.test.ts | 40 ++++++++ src/ts-ast-util/file-name-filter.ts | 22 +++++ src/ts-ast-util/index.ts | 1 + src/ts-ast-util/script-source-helper.ts | 32 +++++-- .../template-expression-resolver.test.ts | 96 +++++++++++++++---- .../template-expression-resolver.ts | 2 + src/ts-ast-util/types.ts | 1 + src/types.ts | 1 + 16 files changed, 205 insertions(+), 35 deletions(-) create mode 100644 src/ts-ast-util/file-name-filter.test.ts create mode 100644 src/ts-ast-util/file-name-filter.ts diff --git a/src/analyzer/analyzer.ts b/src/analyzer/analyzer.ts index 8f9dc00f3..e5651877b 100644 --- a/src/analyzer/analyzer.ts +++ b/src/analyzer/analyzer.ts @@ -17,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() { @@ -57,10 +58,18 @@ export class Analyzer { const documentRegistry = ts.createDocumentRegistry(); const langService = ts.createLanguageService(this._languageServiceHost, documentRegistry); const fragmentRegistry = new FragmentRegistry(); - this._scriptSourceHelper = createScriptSourceHelper({ - languageService: langService, - languageServiceHost: this._languageServiceHost, - }); + 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, @@ -78,7 +87,7 @@ export class Analyzer { const tag = this._pluginConfig.tag; registerDocumentChangeEvent(documentRegistry, { onAcquire: (fileName, sourceFile, version) => { - if (this._languageServiceHost.getScriptFileNames().includes(fileName)) { + if (!isExcluded(fileName) && this._languageServiceHost.getScriptFileNames().includes(fileName)) { const templateLiteralNodes = findAllNodes(sourceFile, node => { if (tag && ts.isTaggedTemplateExpression(node) && hasTagged(node, tag, sourceFile)) { return true; diff --git a/src/analyzer/extractor.ts b/src/analyzer/extractor.ts index 23b87e0f1..fac0b572f 100644 --- a/src/analyzer/extractor.ts +++ b/src/analyzer/extractor.ts @@ -89,6 +89,7 @@ export class Extractor { this._debug('Extract template literals from: '); this._debug(files.map(f => ' ' + f).join(',\n')); files.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 ( diff --git a/src/analyzer/testing/testing-extractor.ts b/src/analyzer/testing/testing-extractor.ts index f80659b96..a2f825317 100644 --- a/src/analyzer/testing/testing-extractor.ts +++ b/src/analyzer/testing/testing-extractor.ts @@ -10,7 +10,14 @@ 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: () => {}, }); 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 839f60d58..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); 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.ts b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts index d4207c66b..0651a129e 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts @@ -38,6 +38,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(); diff --git a/src/graphql-language-service-adapter/testing/adapter-fixture.ts b/src/graphql-language-service-adapter/testing/adapter-fixture.ts index b2dafc49a..8635de319 100644 --- a/src/graphql-language-service-adapter/testing/adapter-fixture.ts +++ b/src/graphql-language-service-adapter/testing/adapter-fixture.ts @@ -24,7 +24,10 @@ export class AdapterFixture { 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, diff --git a/src/language-service-plugin/plugin-module-factory.ts b/src/language-service-plugin/plugin-module-factory.ts index 50c999e6c..6afff83fd 100644 --- a/src/language-service-plugin/plugin-module-factory.ts +++ b/src/language-service-plugin/plugin-module-factory.ts @@ -9,6 +9,7 @@ import { hasTagged, findAllNodes, getShallowText, + createFileNameFilter, } from '../ts-ast-util'; import { LanguageServiceProxyBuilder } from './language-service-proxy-builder'; @@ -21,6 +22,7 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { const tag = config.tag; const removeDuplicatedFragments = config.removeDuplicatedFragments === false ? false : true; const enabledGlobalFragments = config.enabledGlobalFragments === true; + const isExcluded = createFileNameFilter({ specs: config.exclude, projectName: info.project.getProjectName() }); const fragmentRegistry = new FragmentRegistry({ logger }); if (enabledGlobalFragments) { @@ -30,7 +32,7 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { (info.project as any).documentRegistry as ts.DocumentRegistry, { onAcquire: (fileName, sourceFile, version) => { - if (info.languageServiceHost.getScriptFileNames().includes(fileName)) { + if (!isExcluded(fileName) && info.languageServiceHost.getScriptFileNames().includes(fileName)) { // TODO remove before merge logger('acquire script ' + fileName + version); @@ -52,7 +54,7 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { } }, onUpdate: (fileName, sourceFile, version) => { - if (info.languageServiceHost.getScriptFileNames().includes(fileName)) { + if (!isExcluded(fileName) && info.languageServiceHost.getScriptFileNames().includes(fileName)) { if (fragmentRegistry.getFileCurrentVersion(fileName) === version) return; // TODO remove before merge logger('update script ' + fileName + version); @@ -81,7 +83,7 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { ); } - const scriptSourceHelper = createScriptSourceHelper(info); + const scriptSourceHelper = createScriptSourceHelper(info, { exclude: config.exclude }); const adapter = new GraphQLLanguageServiceAdapter(scriptSourceHelper, { schema, schemaErrors, 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 24c2f302e..66f2cc5d6 100644 --- a/src/ts-ast-util/index.ts +++ b/src/ts-ast-util/index.ts @@ -7,3 +7,4 @@ 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/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 263c76f14..fd3692464 100644 --- a/src/ts-ast-util/types.ts +++ b/src/ts-ast-util/types.ts @@ -157,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/types.ts b/src/types.ts index 95d38c89c..5129f8ca7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ import { SchemaConfig } from './schema-manager'; export type TsGraphQLPluginConfigOptions = SchemaConfig & { name: string; + exclude?: string[]; enabledGlobalFragments?: boolean; removeDuplicatedFragments?: boolean; tag?: string; From 8ded67c5a530f1bf75af900f28b4ab234fbf0f89 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 22:33:28 +0900 Subject: [PATCH 30/41] docs: Write about excluded and enabledGlobalFragments options --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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. From 458d5396910d17069cf282bfee83b26d306c7e36 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 13 Mar 2024 23:26:44 +0900 Subject: [PATCH 31/41] chore: Modify FragmentRegistry method name --- src/analyzer/analyzer.ts | 2 +- src/gql-ast-util/fragment-registry.test.ts | 32 +++++------ src/gql-ast-util/fragment-registry.ts | 2 +- .../testing/adapter-fixture.ts | 2 +- .../plugin-module-factory.ts | 56 +++++++------------ 5 files changed, 39 insertions(+), 55 deletions(-) diff --git a/src/analyzer/analyzer.ts b/src/analyzer/analyzer.ts index e5651877b..fb63965f0 100644 --- a/src/analyzer/analyzer.ts +++ b/src/analyzer/analyzer.ts @@ -95,7 +95,7 @@ export class Analyzer { return ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node); } }) as (ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[]; - fragmentRegistry.registerDocument( + fragmentRegistry.registerDocuments( fileName, version, templateLiteralNodes.reduce( diff --git a/src/gql-ast-util/fragment-registry.test.ts b/src/gql-ast-util/fragment-registry.test.ts index 6372d64d9..ea467ec0e 100644 --- a/src/gql-ast-util/fragment-registry.test.ts +++ b/src/gql-ast-util/fragment-registry.test.ts @@ -275,7 +275,7 @@ describe(FragmentRegistry, () => { describe(FragmentRegistry.prototype.getExternalFragments, () => { it('should return empty array when target document can not be parsed', () => { const registry = new FragmentRegistry(); - registry.registerDocument('fragments.ts', '0', [ + registry.registerDocuments('fragments.ts', '0', [ { sourcePosition: 0, text: ` @@ -285,14 +285,14 @@ describe(FragmentRegistry, () => { `, }, ]); - registry.registerDocument('main.ts', '0', [{ sourcePosition: 0, text: 'fragment X on Query {' }]); + 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.registerDocument('fragments.ts', '0', [ + registry.registerDocuments('fragments.ts', '0', [ { sourcePosition: 0, text: ` @@ -310,7 +310,7 @@ describe(FragmentRegistry, () => { `, }, ]); - registry.registerDocument('main.ts', '0', [ + registry.registerDocuments('main.ts', '0', [ { sourcePosition: 0, text: 'fragment FragmentB on Query { ...FragmentA }' }, ]); @@ -323,7 +323,7 @@ describe(FragmentRegistry, () => { it('should not use cached value when dependent fragment changes', () => { const logger = jest.fn(); const registry = new FragmentRegistry({ logger }); - registry.registerDocument('fragments.ts', '0', [ + registry.registerDocuments('fragments.ts', '0', [ { sourcePosition: 0, text: ` @@ -334,13 +334,13 @@ describe(FragmentRegistry, () => { }, ]); - registry.registerDocument('main.ts', '0', [ + 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.registerDocument('fragments.ts', '1', [ + registry.registerDocuments('fragments.ts', '1', [ { sourcePosition: 0, text: ` @@ -362,7 +362,7 @@ describe(FragmentRegistry, () => { it('should not use cached value when FragmentSpread set in target documentString changes', () => { const logger = jest.fn(); const registry = new FragmentRegistry({ logger }); - registry.registerDocument('fragments.ts', '0', [ + registry.registerDocuments('fragments.ts', '0', [ { sourcePosition: 0, text: ` @@ -380,14 +380,14 @@ describe(FragmentRegistry, () => { `, }, ]); - registry.registerDocument('main.ts', '0', [ + 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.registerDocument('main.ts', '1', [ + registry.registerDocuments('main.ts', '1', [ { sourcePosition: 0, text: 'fragment FragmentX on Query { ...FragmentA, ...FragmentB }' }, ]); @@ -404,7 +404,7 @@ describe(FragmentRegistry, () => { it('should not use cached value when FragmentDefinition set in target documentString changes', () => { const logger = jest.fn(); const registry = new FragmentRegistry({ logger }); - registry.registerDocument('fragments.ts', '0', [ + registry.registerDocuments('fragments.ts', '0', [ { sourcePosition: 0, text: ` @@ -414,14 +414,14 @@ describe(FragmentRegistry, () => { `, }, ]); - registry.registerDocument('main.ts', '0', [ + 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.registerDocument('main.ts', '1', [ + registry.registerDocuments('main.ts', '1', [ { sourcePosition: 0, text: ` @@ -451,7 +451,7 @@ describe(FragmentRegistry, () => { it('should use cached value', () => { const logger = jest.fn(); const registry = new FragmentRegistry({ logger }); - registry.registerDocument('fragments.ts', '0', [ + registry.registerDocuments('fragments.ts', '0', [ { sourcePosition: 0, text: ` @@ -469,14 +469,14 @@ describe(FragmentRegistry, () => { `, }, ]); - registry.registerDocument('main.ts', '0', [ + 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.registerDocument('main.ts', '1', [ + registry.registerDocuments('main.ts', '1', [ { sourcePosition: 0, text: 'fragment FragmentX on Query { ...FragmentA, __typename }' }, ]); diff --git a/src/gql-ast-util/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts index 6e7e08413..9dd5278bc 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -362,7 +362,7 @@ export class FragmentRegistry { return externalFragments; } - registerDocument( + registerDocuments( fileName: string, version: string, documentStrings: { text: string; sourcePosition: number }[], diff --git a/src/graphql-language-service-adapter/testing/adapter-fixture.ts b/src/graphql-language-service-adapter/testing/adapter-fixture.ts index 8635de319..8f2660403 100644 --- a/src/graphql-language-service-adapter/testing/adapter-fixture.ts +++ b/src/graphql-language-service-adapter/testing/adapter-fixture.ts @@ -45,6 +45,6 @@ export class AdapterFixture { } addFragment(fragmentDefDoc: string, sourceFileName = this._sourceFileName) { - this._fragmentRegistry.registerDocument(sourceFileName, 'v1', [{ sourcePosition: 0, text: fragmentDefDoc }]); + this._fragmentRegistry.registerDocuments(sourceFileName, 'v1', [{ sourcePosition: 0, text: fragmentDefDoc }]); } } diff --git a/src/language-service-plugin/plugin-module-factory.ts b/src/language-service-plugin/plugin-module-factory.ts index 6afff83fd..e794aeb2a 100644 --- a/src/language-service-plugin/plugin-module-factory.ts +++ b/src/language-service-plugin/plugin-module-factory.ts @@ -26,6 +26,24 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { const fragmentRegistry = new FragmentRegistry({ logger }); if (enabledGlobalFragments) { + const handleAcquireOrUpdate = (fileName: string, sourceFile: ts.SourceFile, version: string) => { + const templateLiteralNodes = findAllNodes(sourceFile, node => { + if (tag && ts.isTaggedTemplateExpression(node) && hasTagged(node, tag, sourceFile)) { + return true; + } else { + return ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node); + } + }) as (ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[]; + fragmentRegistry.registerDocuments( + fileName, + version, + templateLiteralNodes.reduce( + (acc, node) => [...acc, getShallowText(node)], + [] as { text: string; sourcePosition: number }[], + ), + ); + }; + registerDocumentChangeEvent( // Note: // documentRegistry in ts.server.Project is annotated @internal @@ -33,47 +51,13 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { { onAcquire: (fileName, sourceFile, version) => { if (!isExcluded(fileName) && info.languageServiceHost.getScriptFileNames().includes(fileName)) { - // TODO remove before merge - logger('acquire script ' + fileName + version); - - const templateLiteralNodes = findAllNodes(sourceFile, node => { - if (tag && ts.isTaggedTemplateExpression(node) && hasTagged(node, tag, sourceFile)) { - return true; - } else { - return ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node); - } - }) as (ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[]; - fragmentRegistry.registerDocument( - fileName, - version, - templateLiteralNodes.reduce( - (acc, node) => [...acc, getShallowText(node)], - [] as { text: string; sourcePosition: number }[], - ), - ); + handleAcquireOrUpdate(fileName, sourceFile, version); } }, onUpdate: (fileName, sourceFile, version) => { if (!isExcluded(fileName) && info.languageServiceHost.getScriptFileNames().includes(fileName)) { if (fragmentRegistry.getFileCurrentVersion(fileName) === version) return; - // TODO remove before merge - logger('update script ' + fileName + version); - - const templateLiteralNodes = findAllNodes(sourceFile, node => { - if (tag && ts.isTaggedTemplateExpression(node) && hasTagged(node, tag, sourceFile)) { - return true; - } else { - return ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node); - } - }) as (ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[]; - fragmentRegistry.registerDocument( - fileName, - version, - templateLiteralNodes.reduce( - (acc, node) => [...acc, getShallowText(node)], - [] as { text: string; sourcePosition: number }[], - ), - ); + handleAcquireOrUpdate(fileName, sourceFile, version); } }, onRelease: fileName => { From 5f56e6df2b83779797a1bb177fd37aed4e7af7c9 Mon Sep 17 00:00:00 2001 From: Quramy Date: Thu, 14 Mar 2024 00:08:29 +0900 Subject: [PATCH 32/41] refactor: DocumentRegisty handlers --- src/analyzer/analyzer.ts | 18 +++++++----------- .../plugin-module-factory.ts | 18 +++++++----------- src/ts-ast-util/utilily-functions.test.ts | 10 ++-------- src/ts-ast-util/utilily-functions.ts | 15 ++++++--------- 4 files changed, 22 insertions(+), 39 deletions(-) diff --git a/src/analyzer/analyzer.ts b/src/analyzer/analyzer.ts index fb63965f0..3cb51f647 100644 --- a/src/analyzer/analyzer.ts +++ b/src/analyzer/analyzer.ts @@ -88,20 +88,16 @@ export class Analyzer { registerDocumentChangeEvent(documentRegistry, { onAcquire: (fileName, sourceFile, version) => { if (!isExcluded(fileName) && this._languageServiceHost.getScriptFileNames().includes(fileName)) { - const templateLiteralNodes = findAllNodes(sourceFile, node => { - if (tag && ts.isTaggedTemplateExpression(node) && hasTagged(node, tag, sourceFile)) { - return true; - } else { - return ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node); - } - }) as (ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[]; fragmentRegistry.registerDocuments( fileName, version, - templateLiteralNodes.reduce( - (acc, node) => [...acc, getShallowText(node)], - [] as { text: string; sourcePosition: number }[], - ), + 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(getShallowText), ); } }, diff --git a/src/language-service-plugin/plugin-module-factory.ts b/src/language-service-plugin/plugin-module-factory.ts index e794aeb2a..6c9226011 100644 --- a/src/language-service-plugin/plugin-module-factory.ts +++ b/src/language-service-plugin/plugin-module-factory.ts @@ -27,20 +27,16 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { const fragmentRegistry = new FragmentRegistry({ logger }); if (enabledGlobalFragments) { const handleAcquireOrUpdate = (fileName: string, sourceFile: ts.SourceFile, version: string) => { - const templateLiteralNodes = findAllNodes(sourceFile, node => { - if (tag && ts.isTaggedTemplateExpression(node) && hasTagged(node, tag, sourceFile)) { - return true; - } else { - return ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node); - } - }) as (ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[]; fragmentRegistry.registerDocuments( fileName, version, - templateLiteralNodes.reduce( - (acc, node) => [...acc, getShallowText(node)], - [] as { text: string; sourcePosition: number }[], - ), + 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(getShallowText), ); }; diff --git a/src/ts-ast-util/utilily-functions.test.ts b/src/ts-ast-util/utilily-functions.test.ts index 2191a3254..681b464bf 100644 --- a/src/ts-ast-util/utilily-functions.test.ts +++ b/src/ts-ast-util/utilily-functions.test.ts @@ -58,10 +58,7 @@ describe(getShallowText, () => { 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)) as ( - | ts.NoSubstitutionTemplateLiteral - | ts.TemplateExpression - )[]; + const [found] = findAllNodes(s, node => ts.isNoSubstitutionTemplateLiteral(node) && node); const actual = getShallowText(found); expect(actual.text).toBe('abc'); }); @@ -69,10 +66,7 @@ describe(getShallowText, () => { it('should retun replaced text for TemplateExpression', () => { const text = 'const a = `abc${hoge}`;'; const s = ts.createSourceFile('input.ts', text, ts.ScriptTarget.Latest, true); - const [found] = findAllNodes(s, node => ts.isTemplateExpression(node)) as ( - | ts.NoSubstitutionTemplateLiteral - | ts.TemplateExpression - )[]; + const [found] = findAllNodes(s, node => ts.isTemplateExpression(node) && node); const actual = getShallowText(found); expect(actual.text).toBe('abc '); }); diff --git a/src/ts-ast-util/utilily-functions.ts b/src/ts-ast-util/utilily-functions.ts index 5326d4eb1..ce66d253e 100644 --- a/src/ts-ast-util/utilily-functions.ts +++ b/src/ts-ast-util/utilily-functions.ts @@ -78,19 +78,16 @@ export function isTagged(node: ts.Node | undefined, condition: TagCondition, sou return hasTagged(node.parent, condition, source); } -export function getShallowText( - node: ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression, -) { - const n = ts.isTaggedTemplateExpression(node) ? node.template : node; - if (ts.isNoSubstitutionTemplateLiteral(n)) { - return { text: n.text ?? '', sourcePosition: n.pos }; +export function getShallowText(node: ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression) { + if (ts.isNoSubstitutionTemplateLiteral(node)) { + return { text: node.text ?? '', sourcePosition: node.pos }; } else { - let text = n.head.text ?? ''; - for (const span of n.templateSpans) { + let text = node.head.text ?? ''; + for (const span of node.templateSpans) { text += ''.padEnd(span.expression.end - span.expression.pos + 3, ' '); text += span.literal.text; } - return { text, sourcePosition: n.pos }; + return { text, sourcePosition: node.pos }; } } From cc04243c027348bd2137e6455fb704e013a31aed Mon Sep 17 00:00:00 2001 From: Quramy Date: Thu, 14 Mar 2024 13:57:54 +0900 Subject: [PATCH 33/41] fix: Fix calc position for template expression --- src/analyzer/analyzer.ts | 4 +- .../plugin-module-factory.ts | 4 +- src/ts-ast-util/utilily-functions.test.ts | 52 ++++++++++++++----- src/ts-ast-util/utilily-functions.ts | 12 +++-- 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/analyzer/analyzer.ts b/src/analyzer/analyzer.ts index 3cb51f647..ac15acd1c 100644 --- a/src/analyzer/analyzer.ts +++ b/src/analyzer/analyzer.ts @@ -7,7 +7,7 @@ import { hasTagged, findAllNodes, registerDocumentChangeEvent, - getShallowText, + getSanitizedTemplateText, } from '../ts-ast-util'; import { FragmentRegistry } from '../gql-ast-util'; import { SchemaManager, SchemaBuildErrorInfo } from '../schema-manager/schema-manager'; @@ -97,7 +97,7 @@ export class Analyzer { } else if (ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node)) { return node; } - }).map(getShallowText), + }).map(node => getSanitizedTemplateText(node, sourceFile)), ); } }, diff --git a/src/language-service-plugin/plugin-module-factory.ts b/src/language-service-plugin/plugin-module-factory.ts index 6c9226011..81fc3ac62 100644 --- a/src/language-service-plugin/plugin-module-factory.ts +++ b/src/language-service-plugin/plugin-module-factory.ts @@ -8,7 +8,7 @@ import { registerDocumentChangeEvent, hasTagged, findAllNodes, - getShallowText, + getSanitizedTemplateText, createFileNameFilter, } from '../ts-ast-util'; import { LanguageServiceProxyBuilder } from './language-service-proxy-builder'; @@ -36,7 +36,7 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { } else if (ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node)) { return node; } - }).map(getShallowText), + }).map(node => getSanitizedTemplateText(node, sourceFile)), ); }; diff --git a/src/ts-ast-util/utilily-functions.test.ts b/src/ts-ast-util/utilily-functions.test.ts index 681b464bf..893014f11 100644 --- a/src/ts-ast-util/utilily-functions.test.ts +++ b/src/ts-ast-util/utilily-functions.test.ts @@ -2,7 +2,7 @@ import ts from 'typescript'; import { findAllNodes, findNode, - getShallowText, + getSanitizedTemplateText, isTagged, isImportDeclarationWithCondition, mergeImportDeclarationsWithSameModules, @@ -54,21 +54,45 @@ describe(findAllNodes, () => { }); }); -describe(getShallowText, () => { - 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 = getShallowText(found); - expect(actual.text).toBe('abc'); +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')); + }); }); - it('should retun replaced text for TemplateExpression', () => { - const text = 'const a = `abc${hoge}`;'; - const s = ts.createSourceFile('input.ts', text, ts.ScriptTarget.Latest, true); - const [found] = findAllNodes(s, node => ts.isTemplateExpression(node) && node); - const actual = getShallowText(found); - expect(actual.text).toBe('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')); + }); }); }); diff --git a/src/ts-ast-util/utilily-functions.ts b/src/ts-ast-util/utilily-functions.ts index ce66d253e..34826b65b 100644 --- a/src/ts-ast-util/utilily-functions.ts +++ b/src/ts-ast-util/utilily-functions.ts @@ -78,16 +78,22 @@ export function isTagged(node: ts.Node | undefined, condition: TagCondition, sou return hasTagged(node.parent, condition, source); } -export function getShallowText(node: ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression) { +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: node.pos }; + 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: node.pos }; + return { text, sourcePosition }; } } From 640256a36e70ffa5b8f09fa07a2eebbd209ef630 Mon Sep 17 00:00:00 2001 From: Quramy Date: Thu, 14 Mar 2024 14:30:07 +0900 Subject: [PATCH 34/41] feat: Report duplicated fragment error from lang-service --- src/errors/index.ts | 4 ++ src/gql-ast-util/fragment-registry.ts | 4 ++ .../get-semantic-diagonistics.test.ts | 40 +++++++++++++++++++ .../get-semantic-diagonistics.ts | 28 +++++++++++++ .../graphql-language-service-adapter.ts | 37 ++++++++++++----- src/graphql-language-service-adapter/types.ts | 4 +- 6 files changed, 105 insertions(+), 12 deletions(-) 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/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts index 9dd5278bc..c1b4eac13 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -313,6 +313,10 @@ export class FragmentRegistry { }; } + getDuplicaterdFragmentDefinitionMap() { + return this._store.getDuplicatedDefinitonMap(); + } + getExternalFragments(documentStr: string, fileName: string, sourcePosition: number): FragmentDefinitionNode[] { let documentNode: DocumentNode | undefined = undefined; try { 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 fde5495a9..917e1775d 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.test.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.test.ts @@ -314,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 0651a129e..90ab605ab 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts @@ -1,8 +1,11 @@ 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'; function createSchemaErrorDiagnostic( errorInfo: SchemaBuildErrorInfo, @@ -22,6 +25,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; @@ -53,6 +73,14 @@ 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.getDuplicaterdFragmentDefinitionMap().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) 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 e1b81ecff..ff3728495 100644 --- a/src/graphql-language-service-adapter/graphql-language-service-adapter.ts +++ b/src/graphql-language-service-adapter/graphql-language-service-adapter.ts @@ -1,5 +1,5 @@ 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 { getFragmentNamesInDocument, detectDuplicatedFragments, type FragmentRegistry } from '../gql-ast-util'; @@ -7,6 +7,7 @@ import { AnalysisContext, GetCompletionAtPosition, GetSemanticDiagnostics, GetQu 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; @@ -26,6 +27,7 @@ export class GraphQLLanguageServiceAdapter { private readonly _removeDuplicatedFragments: boolean; private readonly _analysisContext: AnalysisContext; private readonly _fragmentRegisry: FragmentRegistry; + private readonly _parsedDocumentCache = new LRUCache(100); constructor( private readonly _helper: ScriptSourceHelper, @@ -74,9 +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), + getDuplicaterdFragmentDefinitionMap: () => this._fragmentRegisry.getDuplicaterdFragmentDefinitionMap(), findTemplateNode: (fileName, position) => this._findTemplateNode(fileName, position), findTemplateNodes: fileName => this._findTemplateNodes(fileName), resolveTemplateInfo: (fileName, node) => this._resolveTemplateInfo(fileName, node), @@ -118,24 +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 }; - try { - const documentNode = parse(resolvedInfo.combinedText); - 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 }; - } 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/types.ts b/src/graphql-language-service-adapter/types.ts index 86fc868da..35d6eb4d6 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, type FragmentDefinitionNode } from 'graphql'; +import { GraphQLSchema, type DocumentNode, type FragmentDefinitionNode } from 'graphql'; import { ScriptSourceHelper, ResolveResult } from '../ts-ast-util'; import { SchemaBuildErrorInfo } from '../schema-manager/schema-manager'; @@ -18,6 +18,8 @@ export interface AnalysisContext { fileName: string, sourcePosition: number, ): FragmentDefinitionNode[]; + getDuplicaterdFragmentDefinitionMap(): Map; + getGraphQLDocumentNode(text: string): DocumentNode | undefined; findTemplateNode( fileName: string, position: number, From 9d27fe8c704e65346f90b28c885632dae2df0324 Mon Sep 17 00:00:00 2001 From: Quramy Date: Thu, 14 Mar 2024 15:12:44 +0900 Subject: [PATCH 35/41] chore: Modify analysis context method type --- src/graphql-language-service-adapter/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphql-language-service-adapter/types.ts b/src/graphql-language-service-adapter/types.ts index 35d6eb4d6..13897729c 100644 --- a/src/graphql-language-service-adapter/types.ts +++ b/src/graphql-language-service-adapter/types.ts @@ -18,7 +18,7 @@ export interface AnalysisContext { fileName: string, sourcePosition: number, ): FragmentDefinitionNode[]; - getDuplicaterdFragmentDefinitionMap(): Map; + getDuplicaterdFragmentDefinitionMap(): Map; getGraphQLDocumentNode(text: string): DocumentNode | undefined; findTemplateNode( fileName: string, From 8d89a35ba2fa3990da79d72be5e4a69dfe540505 Mon Sep 17 00:00:00 2001 From: Quramy Date: Thu, 14 Mar 2024 16:54:44 +0900 Subject: [PATCH 36/41] feat: Report duplicated fragment error from CLI --- project-fixtures/gql-errors-prj/main.ts | 12 +++-- project-fixtures/gql-errors-prj/tsconfig.json | 1 + .../__snapshots__/analyzer.test.ts.snap | 7 +++ src/analyzer/analyzer.test.ts | 25 ++++++++++ src/analyzer/extractor.ts | 3 +- src/analyzer/validator.ts | 48 +++++++++++++++---- 6 files changed, 83 insertions(+), 13 deletions(-) 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/src/analyzer/__snapshots__/analyzer.test.ts.snap b/src/analyzer/__snapshots__/analyzer.test.ts.snap index 3a09dab0c..f7fdf3f89 100644 --- a/src/analyzer/__snapshots__/analyzer.test.ts.snap +++ b/src/analyzer/__snapshots__/analyzer.test.ts.snap @@ -128,6 +128,13 @@ export type MyFragment = { 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 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 6c2ee9daf..91a336a59 100644 --- a/src/analyzer/analyzer.test.ts +++ b/src/analyzer/analyzer.test.ts @@ -170,6 +170,24 @@ const externalFragmentErrorPrj = { ], }; +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', () => { @@ -235,6 +253,13 @@ describe(Analyzer, () => { 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, () => { diff --git a/src/analyzer/extractor.ts b/src/analyzer/extractor.ts index fac0b572f..435d71671 100644 --- a/src/analyzer/extractor.ts +++ b/src/analyzer/extractor.ts @@ -67,7 +67,7 @@ export type ExtractResult = { globalFragments: { definitions: FragmentDefinitionNode[]; definitionMap: Map; - // TODO additional entries from FragmentRegistry + duplicatedDefinitionMap: Map; }; }; @@ -164,6 +164,7 @@ export class Extractor { globalFragments: { definitions: globalDefinitonsWithMap.definitions, definitionMap: globalDefinitonsWithMap.map, + duplicatedDefinitionMap: this._fragmentRegistry.getDuplicaterdFragmentDefinitionMap(), }, }; } diff --git a/src/analyzer/validator.ts b/src/analyzer/validator.ts index b144c491f..a05b7d664 100644 --- a/src/analyzer/validator.ts +++ b/src/analyzer/validator.ts @@ -1,8 +1,23 @@ import { GraphQLSchema } from 'graphql'; 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 { getFragmentNamesInDocument, cloneFragmentMap } from '../gql-ast-util'; + +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[] = []; @@ -10,6 +25,24 @@ export function validate({ fileEntries: extractedResults, globalFragments }: Ext if (!r.resolevedTemplateInfo) return; const { combinedText, getSourcePosition, convertInnerLocation2InnerPosition } = r.resolevedTemplateInfo; 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.duplicatedDefinitionMap.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, @@ -22,12 +55,11 @@ export function validate({ fileEntries: extractedResults, globalFragments }: Ext 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 endPositionOfSource = calcEndPositionSafely( + startPositionOfSource, + getSourcePosition, + convertInnerLocation2InnerPosition(diagnositc.range.end), + ); errors.push( new ErrorWithLocation(diagnositc.message, { fileName: r.fileName, From 80b699558b2b6eae0cb3ed0c4390d9c3fe66ff29 Mon Sep 17 00:00:00 2001 From: Quramy Date: Thu, 14 Mar 2024 17:02:44 +0900 Subject: [PATCH 37/41] refactor: Modify fragment registry signature --- src/analyzer/extractor.ts | 4 ++-- src/analyzer/validator.ts | 2 +- src/gql-ast-util/fragment-registry.ts | 4 ++-- .../get-semantic-diagonistics.ts | 2 +- .../graphql-language-service-adapter.ts | 2 +- src/graphql-language-service-adapter/types.ts | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/analyzer/extractor.ts b/src/analyzer/extractor.ts index 435d71671..67a9b1505 100644 --- a/src/analyzer/extractor.ts +++ b/src/analyzer/extractor.ts @@ -67,7 +67,7 @@ export type ExtractResult = { globalFragments: { definitions: FragmentDefinitionNode[]; definitionMap: Map; - duplicatedDefinitionMap: Map; + duplicatedDefinitions: Set; }; }; @@ -164,7 +164,7 @@ export class Extractor { globalFragments: { definitions: globalDefinitonsWithMap.definitions, definitionMap: globalDefinitonsWithMap.map, - duplicatedDefinitionMap: this._fragmentRegistry.getDuplicaterdFragmentDefinitionMap(), + duplicatedDefinitions: this._fragmentRegistry.getDuplicaterdFragmentDefinitions(), }, }; } diff --git a/src/analyzer/validator.ts b/src/analyzer/validator.ts index a05b7d664..abaa1ccef 100644 --- a/src/analyzer/validator.ts +++ b/src/analyzer/validator.ts @@ -30,7 +30,7 @@ export function validate({ fileEntries: extractedResults, globalFragments }: Ext .map(fragmentDef => [fragmentDef, getSourcePosition(fragmentDef.name.loc!.start)] as const) .filter( ([fragmentDef, { isInOtherExpression }]) => - !isInOtherExpression && globalFragments.duplicatedDefinitionMap.has(fragmentDef.name.value), + !isInOtherExpression && globalFragments.duplicatedDefinitions.has(fragmentDef.name.value), ) .map( ([fragmentDef, { pos: startPositionOfSource }]) => diff --git a/src/gql-ast-util/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts index c1b4eac13..922d0f0c8 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -313,8 +313,8 @@ export class FragmentRegistry { }; } - getDuplicaterdFragmentDefinitionMap() { - return this._store.getDuplicatedDefinitonMap(); + getDuplicaterdFragmentDefinitions() { + return new Set(this._store.getDuplicatedDefinitonMap().keys()); } getExternalFragments(documentStr: string, fileName: string, sourcePosition: number): FragmentDefinitionNode[] { diff --git a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts index 90ab605ab..deb102691 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts @@ -76,7 +76,7 @@ export function getSemanticDiagnostics(ctx: AnalysisContext, delegate: GetSemant const { text, sourcePosition } = getSanitizedTemplateText(n); result.push( ...getFragmentsInDocument(ctx.getGraphQLDocumentNode(text)) - .filter(fragmentDef => ctx.getDuplicaterdFragmentDefinitionMap().has(fragmentDef.name.value)) + .filter(fragmentDef => ctx.getDuplicaterdFragmentDefinitions().has(fragmentDef.name.value)) .map(fragmentDef => createDuplicatedFragmentDefinitonsDiagnostic(n.getSourceFile(), sourcePosition, fragmentDef), ), 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 ff3728495..1a338ba43 100644 --- a/src/graphql-language-service-adapter/graphql-language-service-adapter.ts +++ b/src/graphql-language-service-adapter/graphql-language-service-adapter.ts @@ -80,7 +80,7 @@ export class GraphQLLanguageServiceAdapter { getGlobalFragmentDefinitions: () => this._fragmentRegisry.getFragmentDefinitions(), getExternalFragmentDefinitions: (documentStr, fileName, sourcePosition) => this._fragmentRegisry.getExternalFragments(documentStr, fileName, sourcePosition), - getDuplicaterdFragmentDefinitionMap: () => this._fragmentRegisry.getDuplicaterdFragmentDefinitionMap(), + getDuplicaterdFragmentDefinitions: () => this._fragmentRegisry.getDuplicaterdFragmentDefinitions(), findTemplateNode: (fileName, position) => this._findTemplateNode(fileName, position), findTemplateNodes: fileName => this._findTemplateNodes(fileName), resolveTemplateInfo: (fileName, node) => this._resolveTemplateInfo(fileName, node), diff --git a/src/graphql-language-service-adapter/types.ts b/src/graphql-language-service-adapter/types.ts index 13897729c..7c1e54967 100644 --- a/src/graphql-language-service-adapter/types.ts +++ b/src/graphql-language-service-adapter/types.ts @@ -18,7 +18,7 @@ export interface AnalysisContext { fileName: string, sourcePosition: number, ): FragmentDefinitionNode[]; - getDuplicaterdFragmentDefinitionMap(): Map; + getDuplicaterdFragmentDefinitions(): Set; getGraphQLDocumentNode(text: string): DocumentNode | undefined; findTemplateNode( fileName: string, From 8f00c1f50b5b1c22a5a18360c8240b30f4df8ca9 Mon Sep 17 00:00:00 2001 From: Quramy Date: Thu, 14 Mar 2024 18:18:02 +0900 Subject: [PATCH 38/41] chore: Modify cache size --- src/gql-ast-util/fragment-registry.ts | 2 +- .../graphql-language-service-adapter.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gql-ast-util/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts index 922d0f0c8..a9b4940a6 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -289,7 +289,7 @@ export class FragmentRegistry { }, }); - private _externalFragmentsCache = new LRUCache(200); + private _externalFragmentsCache = new LRUCache(100); private _logger: (msg: string) => void; constructor(options: FragmentRegistryCreateOptions = { logger: () => null }) { 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 1a338ba43..64092a6ea 100644 --- a/src/graphql-language-service-adapter/graphql-language-service-adapter.ts +++ b/src/graphql-language-service-adapter/graphql-language-service-adapter.ts @@ -27,7 +27,7 @@ export class GraphQLLanguageServiceAdapter { private readonly _removeDuplicatedFragments: boolean; private readonly _analysisContext: AnalysisContext; private readonly _fragmentRegisry: FragmentRegistry; - private readonly _parsedDocumentCache = new LRUCache(100); + private readonly _parsedDocumentCache = new LRUCache(500); constructor( private readonly _helper: ScriptSourceHelper, From afe14afbc639a0e43e78aa5b8f1f48ffed1310eb Mon Sep 17 00:00:00 2001 From: Quramy Date: Fri, 15 Mar 2024 03:43:29 +0900 Subject: [PATCH 39/41] fix: Don't report dependent fragment error --- .../__snapshots__/analyzer.test.ts.snap | 4 +- src/analyzer/analyzer.test.ts | 46 +++++++++++++++++- src/analyzer/validator.ts | 47 ++++++++++++------- .../get-semantic-diagonistics.ts | 11 ++++- src/string-util/position-converter.ts | 14 ++++-- 5 files changed, 95 insertions(+), 27 deletions(-) diff --git a/src/analyzer/__snapshots__/analyzer.test.ts.snap b/src/analyzer/__snapshots__/analyzer.test.ts.snap index f7fdf3f89..746b8c86f 100644 --- a/src/analyzer/__snapshots__/analyzer.test.ts.snap +++ b/src/analyzer/__snapshots__/analyzer.test.ts.snap @@ -137,6 +137,6 @@ exports[`Analyzer validate should report duplicatedFragmentDefinitions error 1`] 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 validate project with schema error project 1`] = `"Syntax Error: Unexpected Name "hogehoge"."`; +exports[`Analyzer validate should report missing external fragment refference 1`] = `"Unknown fragment "F2"."`; -exports[`Analyzer validate should validate project with semantic warning project 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 91a336a59..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; @@ -170,6 +171,38 @@ const externalFragmentErrorPrj = { ], }; +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 { @@ -240,7 +273,7 @@ describe(Analyzer, () => { expect(schema).toBeTruthy(); }); - it('should validate project with semantic warning project', async () => { + it('should report missing external fragment refference', async () => { const analyzer = createTestingAnalyzer(externalFragmentErrorPrj); const { errors, schema } = await analyzer.validate(); expect(errors.length).toBe(1); @@ -248,6 +281,17 @@ describe(Analyzer, () => { 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(); diff --git a/src/analyzer/validator.ts b/src/analyzer/validator.ts index abaa1ccef..224b4ed77 100644 --- a/src/analyzer/validator.ts +++ b/src/analyzer/validator.ts @@ -4,6 +4,7 @@ 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 { OutOfRangeError } from '../string-util'; function calcEndPositionSafely( startPositionOfSource: number, @@ -51,24 +52,34 @@ export function validate({ fileEntries: extractedResults, globalFragments }: Ext : []; const diagnostics = getDiagnostics(combinedText, schema, undefined, undefined, externalFragments); diagnostics.forEach(diagnositc => { - const { pos: startPositionOfSource, isInOtherExpression } = getSourcePosition( - convertInnerLocation2InnerPosition(diagnositc.range.start), - ); - 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, - }), - ); + try { + 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; + } }); }); return errors; diff --git a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts index deb102691..a5a2b20f9 100644 --- a/src/graphql-language-service-adapter/get-semantic-diagonistics.ts +++ b/src/graphql-language-service-adapter/get-semantic-diagonistics.ts @@ -6,6 +6,7 @@ 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, @@ -132,8 +133,14 @@ export function getSemanticDiagnostics(ctx: AnalysisContext, delegate: GetSemant } else { result.push(translateDiagnostic(d, file, startPositionOfSource, length)); } - } catch { - return; + } 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/string-util/position-converter.ts b/src/string-util/position-converter.ts index 39c4b1a28..f63559e9b 100644 --- a/src/string-util/position-converter.ts +++ b/src/string-util/position-converter.ts @@ -1,7 +1,13 @@ +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 Error('OutOfRange'); + throw new OutOfRangeError(); } } let l = 0, @@ -27,14 +33,14 @@ export function location2pos( ic = 0; if (throwErrorIfOutOfRange) { if (location.line < 0 || location.character < 0) { - throw new Error('OutOfRange'); + 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 Error('OutOfRange'); + throw new OutOfRangeError(); } if (ic === location.character) { return i; @@ -48,7 +54,7 @@ export function location2pos( } } if (throwErrorIfOutOfRange) { - throw new Error('OutOfRange'); + throw new OutOfRangeError(); } return content.length; } From eb26ed63b841bfbade3964fe52d6b9af3449ce4e Mon Sep 17 00:00:00 2001 From: Quramy Date: Fri, 15 Mar 2024 06:28:16 +0900 Subject: [PATCH 40/41] fix: Don't display excluded files to verbose log --- src/analyzer/extractor.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/analyzer/extractor.ts b/src/analyzer/extractor.ts index 67a9b1505..3a1b980b0 100644 --- a/src/analyzer/extractor.ts +++ b/src/analyzer/extractor.ts @@ -86,9 +86,10 @@ export class Extractor { 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)) From cc282dea1b3eeee5020675f08ec38852089570a2 Mon Sep 17 00:00:00 2001 From: Quramy Date: Fri, 15 Mar 2024 06:29:11 +0900 Subject: [PATCH 41/41] chore: Turn off global fragments for react-apollo example --- .../react-apollo-prj/src/__generated__/git-hub-query.ts | 6 +++--- project-fixtures/react-apollo-prj/src/index.tsx | 1 + project-fixtures/react-apollo-prj/tsconfig.json | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/project-fixtures/react-apollo-prj/src/__generated__/git-hub-query.ts b/project-fixtures/react-apollo-prj/src/__generated__/git-hub-query.ts index c14c0d021..2fc8bff25 100644 --- a/project-fixtures/react-apollo-prj/src/__generated__/git-hub-query.ts +++ b/project-fixtures/react-apollo-prj/src/__generated__/git-hub-query.ts @@ -1,6 +1,9 @@ /* eslint-disable */ /* This is an autogenerated file. Do not edit this file directly! */ import { TypedQueryDocumentNode } from "graphql"; +export type RepositoryFragment = { + description: string | null; +}; export type GitHubQuery = { viewer: { repositories: { @@ -14,6 +17,3 @@ export type GitHubQueryVariables = { first: number; }; export type GitHubQueryDocument = TypedQueryDocumentNode; -export type RepositoryFragment = { - description: string | null; -}; diff --git a/project-fixtures/react-apollo-prj/src/index.tsx b/project-fixtures/react-apollo-prj/src/index.tsx index ad9787dfd..9a2879b08 100644 --- a/project-fixtures/react-apollo-prj/src/index.tsx +++ b/project-fixtures/react-apollo-prj/src/index.tsx @@ -9,6 +9,7 @@ const repositoryFragment = gql` `; const query = gql` + ${repositoryFragment} query GitHubQuery($first: Int!) { viewer { repositories(first: $first) { diff --git a/project-fixtures/react-apollo-prj/tsconfig.json b/project-fixtures/react-apollo-prj/tsconfig.json index 12c1dc82c..fd0b14701 100644 --- a/project-fixtures/react-apollo-prj/tsconfig.json +++ b/project-fixtures/react-apollo-prj/tsconfig.json @@ -11,10 +11,10 @@ { "name": "ts-graphql-plugin", "schema": "schema.graphql", - "enabledGlobalFragments": true, "tag": "gql", + "exclude": ["src/__generated__"], "typegen": { - "addons": ["../../addons/typed-query-document"] + "addons": ["ts-graphql-plugin/addons/typed-query-document"] } } ]