diff --git a/packages/graphql-language-service-server/src/GraphQLCache.ts b/packages/graphql-language-service-server/src/GraphQLCache.ts index 9fb18dd49a..5625c3061f 100644 --- a/packages/graphql-language-service-server/src/GraphQLCache.ts +++ b/packages/graphql-language-service-server/src/GraphQLCache.ts @@ -424,32 +424,6 @@ export class GraphQLCache implements GraphQLCacheInterface { return patterns; }; - async _updateGraphQLFileListCache( - graphQLFileMap: Map, - metrics: { size: number; mtime: number }, - filePath: Uri, - exists: boolean, - ): Promise> { - const fileAndContent = exists - ? await this.promiseToReadGraphQLFile(filePath) - : null; - - const existingFile = graphQLFileMap.get(filePath); - - // 3 cases for the cache invalidation: create/modify/delete. - // For create/modify, swap the existing entry if available; - // otherwise, just push in the new entry created. - // For delete, check `exists` and splice the file out. - if (existingFile && !exists) { - graphQLFileMap.delete(filePath); - } else if (fileAndContent) { - const graphQLFileInfo = { ...fileAndContent, ...metrics }; - graphQLFileMap.set(filePath, graphQLFileInfo); - } - - return graphQLFileMap; - } - async updateFragmentDefinition( rootDir: Uri, filePath: Uri, @@ -490,32 +464,6 @@ export class GraphQLCache implements GraphQLCacheInterface { } } - async updateFragmentDefinitionCache( - rootDir: Uri, - filePath: Uri, - exists: boolean, - ): Promise { - const fileAndContent = exists - ? await this.promiseToReadGraphQLFile(filePath) - : null; - // In the case of fragment definitions, the cache could just map the - // definition name to the parsed ast, whether or not it existed - // previously. - // For delete, remove the entry from the set. - if (!exists) { - const cache = this._fragmentDefinitionsCache.get(rootDir); - if (cache) { - cache.delete(filePath); - } - } else if (fileAndContent?.queries) { - await this.updateFragmentDefinition( - rootDir, - filePath, - fileAndContent.queries, - ); - } - } - async updateObjectTypeDefinition( rootDir: Uri, filePath: Uri, @@ -664,18 +612,17 @@ export class GraphQLCache implements GraphQLCacheInterface { if (schemaPath && schemaKey) { schemaCacheKey = schemaKey as string; - // Maybe use cache - // if (this._schemaMap.has(schemaCacheKey)) { - // schema = this._schemaMap.get(schemaCacheKey); - // if (schema) { - // return queryHasExtensions - // ? this._extendSchema(schema, schemaPath, schemaCacheKey) - // : schema; - // } - // } - // Read from disk schema = await projectConfig.getSchema(); + + if (this._schemaMap.has(schemaCacheKey)) { + schema = this._schemaMap.get(schemaCacheKey); + if (schema) { + return queryHasExtensions + ? this._extendSchema(schema, schemaPath, schemaCacheKey) + : schema; + } + } } const customDirectives = projectConfig?.extensions?.customDirectives; diff --git a/packages/graphql-language-service-server/src/MessageProcessor.ts b/packages/graphql-language-service-server/src/MessageProcessor.ts index fcdda7647c..9dd6164547 100644 --- a/packages/graphql-language-service-server/src/MessageProcessor.ts +++ b/packages/graphql-language-service-server/src/MessageProcessor.ts @@ -314,7 +314,7 @@ export class MessageProcessor { async handleDidOpenOrSaveNotification( params: DidSaveTextDocumentParams | DidOpenTextDocumentParams, - ): Promise { + ): Promise { /** * Initialize the LSP server when the first file is opened or saved, * so that we can access the user settings for config rootDir, etc @@ -327,7 +327,7 @@ export class MessageProcessor { // don't try to initialize again if we've already tried // and the graphql config file or package.json entry isn't even there if (this._isGraphQLConfigMissing === true && !isGraphQLConfigFile) { - return null; + return { uri: params.textDocument.uri, diagnostics: [] }; } // then initial call to update graphql config await this._updateGraphQLConfig(); @@ -360,13 +360,10 @@ export class MessageProcessor { contents = this._parser(text, uri); await this._invalidateCache(textDocument, uri, contents); - } else { - if (isGraphQLConfigFile) { - this._logger.info('updating graphql config'); - await this._updateGraphQLConfig(); - return { uri, diagnostics: [] }; - } - return null; + } else if (isGraphQLConfigFile) { + this._logger.info('updating graphql config'); + await this._updateGraphQLConfig(); + return { uri, diagnostics: [] }; } if (!this._graphQLCache) { return { uri, diagnostics }; diff --git a/packages/graphql-language-service-server/src/__tests__/MessageProcessor.spec.ts b/packages/graphql-language-service-server/src/__tests__/MessageProcessor.spec.ts index f5d08acc2c..6b89dd6a9f 100644 --- a/packages/graphql-language-service-server/src/__tests__/MessageProcessor.spec.ts +++ b/packages/graphql-language-service-server/src/__tests__/MessageProcessor.spec.ts @@ -1,173 +1,71 @@ -import { MessageProcessor } from '../MessageProcessor'; - -jest.mock('../Logger'); - -import { NoopLogger } from '../Logger'; import mockfs from 'mock-fs'; import { join } from 'node:path'; -import { MockProject } from './__utils__/MockProject'; -import { readFileSync } from 'node:fs'; -import { FileChangeTypeKind } from 'graphql-language-service'; +import { MockFile, MockProject } from './__utils__/MockProject'; +// import { readFileSync } from 'node:fs'; import { FileChangeType } from 'vscode-languageserver'; +import { serializeRange } from './__utils__/utils'; import { readFile } from 'node:fs/promises'; -describe('MessageProcessor with no config', () => { - let messageProcessor: MessageProcessor; - const mockRoot = join('/tmp', 'test'); - let loggerSpy: jest.SpyInstance; - let mockProcessor; - - beforeEach(() => { - mockProcessor = (query: string, config?: string) => { - const items = { - 'query.graphql': query, - }; - if (config) { - items['graphql.config.js'] = config; - } - const files: Parameters[0] = { - [mockRoot]: mockfs.directory({ - items, - }), - 'node_modules/parse-json': mockfs.load('node_modules/parse-json'), - 'node_modules/error-ex': mockfs.load('node_modules/error-ex'), - 'node_modules/is-arrayish': mockfs.load('node_modules/is-arrayish'), - 'node_modules/json-parse-even-better-errors': mockfs.load( - 'node_modules/json-parse-even-better-errors', - ), - 'node_modules/lines-and-columns': mockfs.load( - 'node_modules/lines-and-columns', - ), - 'node_modules/@babel': mockfs.load('node_modules/@babel'), - }; - mockfs(files); - const logger = new NoopLogger(); - loggerSpy = jest.spyOn(logger, 'error'); - messageProcessor = new MessageProcessor({ - // @ts-ignore - connection: { - // @ts-ignore - get workspace() { - return { - async getConfiguration() { - return []; - }, - }; - }, - }, - logger, - graphqlFileExtensions: ['graphql'], - loadConfigOptions: { rootDir: mockRoot }, - }); - }; - }); +const defaultFiles = [ + ['query.graphql', 'query { bar ...B }'], + ['fragments.graphql', 'fragment B on Foo { bar }'], +] as MockFile[]; +const schemaFile: MockFile = [ + 'schema.graphql', + 'type Query { foo: Foo }\n\ntype Foo { bar: String }', +]; +describe('MessageProcessor with no config', () => { afterEach(() => { mockfs.restore(); }); it('fails to initialize with empty config file', async () => { - mockProcessor('query { foo }', ''); - await messageProcessor.handleInitializeRequest( - // @ts-ignore - { - rootPath: mockRoot, - }, - null, - mockRoot, - ); - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - text: 'query { foo }', - uri: `${mockRoot}/query.graphql`, - version: 1, - }, - }); - expect(messageProcessor._isInitialized).toEqual(false); - expect(messageProcessor._isGraphQLConfigMissing).toEqual(true); - expect(loggerSpy).toHaveBeenCalledTimes(1); - expect(loggerSpy).toHaveBeenCalledWith( + const project = new MockProject({ + files: [...defaultFiles, ['graphql.config.json', '']], + }); + await project.init(); + expect(project.lsp._isInitialized).toEqual(false); + expect(project.lsp._isGraphQLConfigMissing).toEqual(true); + expect(project.lsp._logger.info).toHaveBeenCalledTimes(1); + expect(project.lsp._logger.error).toHaveBeenCalledTimes(1); + expect(project.lsp._logger.error).toHaveBeenCalledWith( expect.stringMatching( /GraphQL Config file is not available in the provided config directory/, ), ); }); it('fails to initialize with no config file present', async () => { - mockProcessor('query { foo }'); - await messageProcessor.handleInitializeRequest( - // @ts-ignore - { - rootPath: mockRoot, - }, - null, - mockRoot, - ); - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - text: 'query { foo }', - uri: `${mockRoot}/query.graphql`, - version: 1, - }, + const project = new MockProject({ + files: [...defaultFiles], }); - expect(messageProcessor._isInitialized).toEqual(false); - expect(messageProcessor._isGraphQLConfigMissing).toEqual(true); - expect(loggerSpy).toHaveBeenCalledTimes(1); - expect(loggerSpy).toHaveBeenCalledWith( + await project.init(); + expect(project.lsp._isInitialized).toEqual(false); + expect(project.lsp._isGraphQLConfigMissing).toEqual(true); + expect(project.lsp._logger.error).toHaveBeenCalledTimes(1); + expect(project.lsp._logger.error).toHaveBeenCalledWith( expect.stringMatching( /GraphQL Config file is not available in the provided config directory/, ), ); }); it('initializes when presented with a valid config later', async () => { - mockProcessor('query { foo }'); - await messageProcessor.handleInitializeRequest( - // @ts-ignore - { - rootPath: mockRoot, - }, - null, - mockRoot, - ); - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - text: 'query { foo }', - uri: `${mockRoot}/query.graphql`, - version: 1, - }, + const project = new MockProject({ + files: [...defaultFiles], }); - expect(messageProcessor._isInitialized).toEqual(false); - expect(loggerSpy).toHaveBeenCalledTimes(1); + await project.init(); + expect(project.lsp._isInitialized).toEqual(false); + expect(project.lsp._isGraphQLConfigMissing).toEqual(true); + expect(project.lsp._logger.error).toHaveBeenCalledTimes(1); - // todo: get mockfs working with in-test file changes - // mockfs({ - // [mockRoot]: mockfs.directory({ - // mode: 0o755, - // items: { - // 'schema.graphql': - // 'type Query { foo: String }\nschema { query: Query }', - // 'graphql.config.js': mockfs.file({ - // content: 'module.exports = { schema: "schema.graphql" };', - // mode: 0o644, - // }), - // 'query.graphql': 'query { foo }', - // }, - // }), - // }); - // // console.log(readdirSync(`${mockRoot}`)); - // await messageProcessor.handleDidOpenOrSaveNotification({ - // textDocument: { - // text: 'module.exports = { schema: "schema.graphql" }', - // uri: `${mockRoot}/graphql.config.js`, - // version: 2, - // }, - // }); - - // expect(messageProcessor._isGraphQLConfigMissing).toEqual(false); - - // expect(loggerSpy).toHaveBeenCalledWith( - // expect.stringMatching( - // /GraphQL Config file is not available in the provided config directory/, - // ), - // ); + project.changeFile( + 'graphql.config.json', + '{ "schema": "./schema.graphql" }', + ); + await project.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { uri: project.uri('schema.graphql'), type: FileChangeType.Changed }, + ], + }); }); }); @@ -175,92 +73,105 @@ describe('project with simple config and graphql files', () => { afterEach(() => { mockfs.restore(); }); - it.only('caches files and schema with .graphql file config', async () => { + it('caches files and schema with .graphql file config, and the schema updates with watched file changes', async () => { const project = new MockProject({ files: [ [ 'graphql.config.json', '{ "schema": "./schema.graphql", "documents": "./**.graphql" }', ], - [ - 'schema.graphql', - 'type Query { foo: Foo }\n\ntype Foo { bar: String }', - ], - ['query.graphql', 'query { bar ...B }'], - ['fragments.graphql', 'fragment B on Foo { bar }'], + ...defaultFiles, + schemaFile, ], }); - await project.lsp.handleInitializeRequest({ - rootPath: project.root, - rootUri: project.root, - capabilities: {}, - processId: 200, - workspaceFolders: null, - }); - await project.lsp.handleDidOpenOrSaveNotification({ - textDocument: { uri: project.uri('query.graphql') }, - }); + await project.init('query.graphql'); expect(project.lsp._logger.error).not.toHaveBeenCalled(); // console.log(project.lsp._graphQLCache.getSchema('schema.graphql')); expect(await project.lsp._graphQLCache.getSchema()).toBeDefined(); // TODO: for some reason the cache result formats the graphql query?? + const docCache = project.lsp._textDocumentCache; expect( - project.lsp._textDocumentCache.get(project.uri('query.graphql')) - .contents[0].query, + docCache.get(project.uri('query.graphql'))!.contents[0].query, ).toContain('...B'); const definitions = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('fragments.graphql') }, position: { character: 16, line: 0 }, }); expect(definitions[0].uri).toEqual(project.uri('schema.graphql')); - expect(JSON.parse(JSON.stringify(definitions[0].range.end))).toEqual({ + + expect(serializeRange(definitions[0].range).end).toEqual({ line: 2, character: 24, }); - // TODO: get mockfs working so we can change watched files. - // currently, when I run this, it removes the file entirely + + const definitionsAgain = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('fragments.graphql') }, + position: { character: 16, line: 0 }, + }); + expect(definitionsAgain[0].uri).toEqual(project.uri('schema.graphql')); + + expect(serializeRange(definitionsAgain[0].range).end).toEqual({ + line: 2, + character: 24, + }); + // change the file to make the fragment invalid project.changeFile( 'schema.graphql', - 'type Query { foo: Foo }\n\n type Test { test: String }\n\n\n\n\ntype Foo { bad: Int, bar: String }', + // now Foo has a bad field, the fragment should be invalid + 'type Query { foo: Foo }\n\n type Test { test: String }\n\n\n\n\ntype Foo { bad: Int }', ); await project.lsp.handleWatchedFilesChangedNotification({ changes: [ { uri: project.uri('schema.graphql'), type: FileChangeType.Changed }, ], }); - const definitionsAgain = await project.lsp.handleDefinitionRequest({ - textDocument: { uri: project.uri('fragments.graphql') }, + const typeCache = + project.lsp._graphQLCache._typeDefinitionsCache.get('/tmp/test-default'); + + expect(typeCache?.get('Test')?.definition.name.value).toEqual('Test'); + // TODO: this fragment should now be invalid + // const result = await project.lsp.handleDidOpenOrSaveNotification({ + // textDocument: { uri: project.uri('fragments.graphql') }, + // }); + // expect(result.diagnostics).toEqual([]); + + project.changeFile( + 'fragments.graphql', + 'fragment B on Foo { bear }\n\nfragment A on Foo { bar }', + ); + + await project.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { uri: project.uri('fragments.graphql'), type: FileChangeType.Changed }, + ], + }); + const fragCache = + project.lsp._graphQLCache._fragmentDefinitionsCache.get( + '/tmp/test-default', + ); + expect(fragCache?.get('A')?.definition.name.value).toEqual('A'); + // TODO: get this working + const definitionsThrice = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('query.graphql') }, position: { character: 16, line: 0 }, }); - expect(definitionsAgain[0].uri).toEqual(project.uri('schema.graphql')); + expect(definitionsThrice[0].uri).toEqual(project.uri('fragments.graphql')); // TODO: this should change when a watched file changes??? - expect(JSON.parse(JSON.stringify(definitions[0].range.end))).toEqual({ - line: 2, - character: 24, - }); }); it('caches files and schema with a URL config', async () => { const project = new MockProject({ files: [ + ['query.graphql', 'query { bar }'], + ['fragments.graphql', 'fragment Ep on Episode {\n created \n}'], [ 'graphql.config.json', '{ "schema": "https://rickandmortyapi.com/graphql", "documents": "./**.graphql" }', ], - - ['query.graphql', 'query { bar }'], - ['fragments.graphql', 'fragment Ep on Episode {\n created \n}'], ], }); - await project.lsp.handleInitializeRequest({ - rootPath: project.root, - rootUri: project.root, - capabilities: {}, - processId: 200, - workspaceFolders: null, - }); - await project.lsp.handleDidOpenOrSaveNotification({ - textDocument: { uri: project.uri('query.graphql') }, - }); + + await project.init('query.graphql'); + await project.lsp.handleDidChangeNotification({ textDocument: { uri: project.uri('query.graphql'), version: 1 }, contentChanges: [{ text: 'query { episodes { results { ...Ep } } }' }], @@ -268,12 +179,13 @@ describe('project with simple config and graphql files', () => { expect(project.lsp._logger.error).not.toHaveBeenCalled(); // console.log(project.lsp._graphQLCache.getSchema('schema.graphql')); expect(await project.lsp._graphQLCache.getSchema()).toBeDefined(); - const file = readFileSync( + const file = await readFile( join( '/tmp/graphql-language-service/test/projects/default/generated-schema.graphql', ), + { encoding: 'utf-8' }, ); - expect(file.toString('utf-8').split('\n').length).toBeGreaterThan(10); + expect(file.split('\n').length).toBeGreaterThan(10); const hover = await project.lsp.handleHoverRequest({ position: { character: 10, @@ -290,7 +202,7 @@ describe('project with simple config and graphql files', () => { }); // ensure that fragment definitions work expect(definitions[0].uri).toEqual(project.uri('fragments.graphql')); - expect(JSON.parse(JSON.stringify(definitions[0].range))).toEqual({ + expect(serializeRange(definitions[0].range)).toEqual({ start: { line: 0, character: 0, diff --git a/packages/graphql-language-service-server/src/__tests__/__utils__/MockProject.ts b/packages/graphql-language-service-server/src/__tests__/__utils__/MockProject.ts index 59e7b8895a..f1511c11df 100644 --- a/packages/graphql-language-service-server/src/__tests__/__utils__/MockProject.ts +++ b/packages/graphql-language-service-server/src/__tests__/__utils__/MockProject.ts @@ -3,6 +3,8 @@ import { MessageProcessor } from '../../MessageProcessor'; import { Logger as VSCodeLogger } from 'vscode-jsonrpc'; import { URI } from 'vscode-uri'; +export type MockFile = [filename: string, text: string]; + export class MockLogger implements VSCodeLogger { error = jest.fn(); warn = jest.fn(); @@ -28,17 +30,19 @@ const modules = [ 'ansi-regex', 'js-tokens', 'escape-string-regexp', + 'jest-worker', ]; const defaultMocks = modules.reduce((acc, module) => { acc[`node_modules/${module}`] = mockfs.load(`node_modules/${module}`); return acc; }, {}); -type Files = [filename: string, text: string][]; +type File = [filename: string, text: string]; +type Files = File[]; export class MockProject { private root: string; - private files: Files; + private fileCache: Map; private messageProcessor: MessageProcessor; constructor({ files = [], @@ -50,7 +54,7 @@ export class MockProject { settings?: [name: string, vale: any][]; }) { this.root = root; - this.files = files; + this.fileCache = new Map(files); this.mockFiles(); this.messageProcessor = new MessageProcessor({ @@ -67,9 +71,25 @@ export class MockProject { loadConfigOptions: { rootDir: root }, }); } + public async init(filename?: string, fileText?: string) { + await this.lsp.handleInitializeRequest({ + rootPath: this.root, + rootUri: this.root, + capabilities: {}, + processId: 200, + workspaceFolders: null, + }); + return this.lsp.handleDidOpenOrSaveNotification({ + textDocument: { + uri: this.uri(filename || this.uri('query.graphql')), + version: 1, + text: this.fileCache.get('query.graphql') || fileText, + }, + }); + } private mockFiles() { const mockFiles = { ...defaultMocks }; - this.files.map(([filename, text]) => { + Array.from(this.fileCache).map(([filename, text]) => { mockFiles[this.filePath(filename)] = text; }); mockfs(mockFiles); @@ -81,7 +101,7 @@ export class MockProject { return URI.file(this.filePath(filename)).toString(); } changeFile(filename: string, text: string) { - this.files.push([filename, text]); + this.fileCache.set(filename, text); this.mockFiles(); } get lsp() { diff --git a/packages/graphql-language-service-server/src/__tests__/__utils__/utils.ts b/packages/graphql-language-service-server/src/__tests__/__utils__/utils.ts new file mode 100644 index 0000000000..4ad1eff2c2 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/__utils__/utils.ts @@ -0,0 +1,4 @@ +import { Range } from 'vscode-languageserver'; + +export const serializeRange = (range: Range) => + JSON.parse(JSON.stringify(range));