graphql-language-service-server
Version:
Server process backing the GraphQL Language Service
1,418 lines (1,292 loc) • 44.6 kB
text/typescript
/**
* Copyright (c) 2021 GraphQL Contributors
* All rights reserved.
*
* This source code is licensed under the license found in the
* LICENSE file in the root directory of this source tree.
*
*/
// do not change to node:fs import, or it will break mock-fs
import { existsSync, mkdirSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import * as path from 'node:path';
import { URI } from 'vscode-uri';
import {
CachedContent,
Uri,
GraphQLConfig,
GraphQLProjectConfig,
FileChangeTypeKind,
Range,
Position,
IPosition,
} from 'graphql-language-service';
import { GraphQLLanguageService } from './GraphQLLanguageService';
import type {
CompletionParams,
FileEvent,
VersionedTextDocumentIdentifier,
DidSaveTextDocumentParams,
DidOpenTextDocumentParams,
DidChangeConfigurationParams,
Diagnostic,
CompletionList,
CancellationToken,
Hover,
InitializeResult,
Location,
PublishDiagnosticsParams,
DidChangeTextDocumentParams,
DidCloseTextDocumentParams,
DidChangeWatchedFilesParams,
InitializeParams,
Range as RangeType,
Position as VscodePosition,
TextDocumentPositionParams,
DocumentSymbolParams,
SymbolInformation,
WorkspaceSymbolParams,
Connection,
DidChangeConfigurationRegistrationOptions,
} from 'vscode-languageserver/node';
import type { UnnormalizedTypeDefPointer } from '@graphql-tools/load';
import { getGraphQLCache, GraphQLCache } from './GraphQLCache';
import { parseDocument } from './parseDocument';
import { printSchema, visit, parse, FragmentDefinitionNode } from 'graphql';
import { tmpdir } from 'node:os';
import {
ConfigEmptyError,
ConfigInvalidError,
ConfigNotFoundError,
LoaderNoResultError,
ProjectNotFoundError,
} from 'graphql-config';
import type { LoadConfigOptions, LocateCommand } from './types';
import {
DEFAULT_SUPPORTED_EXTENSIONS,
SupportedExtensionsEnum,
} from './constants';
import { NoopLogger, Logger } from './Logger';
import glob from 'fast-glob';
import { isProjectSDLOnly, unwrapProjectSchema } from './common';
import { DefinitionQueryResponse } from 'graphql-language-service/src/interface';
import { default as debounce } from 'debounce-promise';
const configDocLink =
'https://www.npmjs.com/package/graphql-language-service-server#user-content-graphql-configuration-file';
type CachedDocumentType = {
version: number;
contents: CachedContent[];
};
function toPosition(position: VscodePosition): IPosition {
return new Position(position.line, position.character);
}
export class MessageProcessor {
private _connection: Connection;
private _graphQLCache!: GraphQLCache;
private _languageService!: GraphQLLanguageService;
private _textDocumentCache = new Map<string, CachedDocumentType>();
private _isInitialized = false;
private _isGraphQLConfigMissing: boolean | null = null;
private _willShutdown = false;
private _logger: Logger | NoopLogger;
private _parser: (text: string, uri: string) => Promise<CachedContent[]>;
private _tmpDir: string;
private _tmpDirBase: string;
private _loadConfigOptions: LoadConfigOptions;
private _rootPath: string = process.cwd();
private _settings: any;
private _providedConfig?: GraphQLConfig;
constructor({
logger,
fileExtensions,
graphqlFileExtensions,
loadConfigOptions,
config,
parser,
tmpDir,
connection,
}: {
logger: Logger | NoopLogger;
fileExtensions: ReadonlyArray<SupportedExtensionsEnum>;
graphqlFileExtensions: string[];
loadConfigOptions: LoadConfigOptions;
config?: GraphQLConfig;
parser?: typeof parseDocument;
tmpDir?: string;
connection: Connection;
}) {
if (config) {
this._providedConfig = config;
}
this._connection = connection;
this._logger = logger;
this._parser = async (text, uri) => {
const p = parser ?? parseDocument;
return p(text, uri, fileExtensions, graphqlFileExtensions, this._logger);
};
this._tmpDir = tmpDir || tmpdir();
this._tmpDirBase = path.join(this._tmpDir, 'graphql-language-service');
// use legacy mode by default for backwards compatibility
this._loadConfigOptions = { legacy: true, ...loadConfigOptions };
if (!existsSync(this._tmpDirBase)) {
void mkdirSync(this._tmpDirBase);
}
}
get connection(): Connection {
return this._connection;
}
set connection(connection: Connection) {
this._connection = connection;
}
public async handleInitializeRequest(
params: InitializeParams,
_token?: CancellationToken,
configDir?: string,
): Promise<InitializeResult> {
if (!params) {
throw new Error('`params` argument is required to initialize.');
}
const serverCapabilities: InitializeResult = {
capabilities: {
workspaceSymbolProvider: true,
documentSymbolProvider: true,
completionProvider: {
resolveProvider: true,
triggerCharacters: [' ', ':', '$', '(', '@', '\n'],
},
definitionProvider: true,
textDocumentSync: 1,
hoverProvider: true,
workspace: {
workspaceFolders: {
supported: true,
changeNotifications: true,
},
},
},
};
this._rootPath = configDir
? configDir.trim()
: params.rootUri || this._rootPath;
if (!this._rootPath) {
this._logger.warn(
'no rootPath configured in extension or server, defaulting to cwd',
);
}
this._logger.info(
JSON.stringify({
type: 'usage',
messageType: 'initialize',
}),
);
return serverCapabilities;
}
// TODO next: refactor (most of) this into the `GraphQLCache` class
async _initializeGraphQLCaches() {
const settings = await this._connection.workspace.getConfiguration({
section: 'graphql-config',
});
const vscodeSettings = await this._connection.workspace.getConfiguration({
section: 'vscode-graphql',
});
// TODO: eventually we will instantiate an instance of this per workspace,
// so rootDir should become that workspace's rootDir
this._settings = { ...settings, ...vscodeSettings };
const rootDir = this._settings?.load?.rootDir.length
? this._settings?.load?.rootDir
: this._rootPath;
if (settings?.dotEnvPath) {
require('dotenv').config({
path: path.resolve(rootDir, settings.dotEnvPath),
});
}
this._rootPath = rootDir;
this._loadConfigOptions = {
...Object.keys(this._settings?.load ?? {}).reduce((agg, key) => {
const value = this._settings?.load[key];
if (value === undefined || value === null) {
delete agg[key];
}
return agg;
}, this._settings.load ?? {}),
rootDir,
};
const onSchemaChange = debounce(async (project: GraphQLProjectConfig) => {
const { cacheSchemaFileForLookup } =
this.getCachedSchemaSettings(project);
if (!cacheSchemaFileForLookup) {
return;
}
const unwrappedSchema = unwrapProjectSchema(project);
const sdlOnly = isProjectSDLOnly(unwrappedSchema);
if (sdlOnly) {
return;
}
return this.cacheConfigSchemaFile(project);
}, 400);
try {
// now we have the settings so we can re-build the logger
this._logger.level = this._settings?.debug === true ? 1 : 0;
// createServer() can be called with a custom config object, and
// this is a public interface that may be used by customized versions of the server
if (this._providedConfig) {
this._graphQLCache = new GraphQLCache({
config: this._providedConfig,
logger: this._logger,
parser: this._parser,
configDir: rootDir,
onSchemaChange,
schemaCacheTTL: this._settings?.schemaCacheTTL,
});
this._languageService = new GraphQLLanguageService(
this._graphQLCache,
this._logger,
);
} else {
// reload the graphql cache
this._graphQLCache = await getGraphQLCache({
parser: this._parser,
loadConfigOptions: this._loadConfigOptions,
logger: this._logger,
onSchemaChange,
schemaCacheTTL: this._settings?.schemaCacheTTL,
});
this._languageService = new GraphQLLanguageService(
this._graphQLCache,
this._logger,
);
}
const config = this._graphQLCache.getGraphQLConfig();
if (config) {
await this._cacheAllProjectFiles(config);
// TODO: per project lazy instantiation.
// we had it working before, but it seemed like it caused bugs
// which were caused by something else.
// thus. _isInitialized should be replaced with something like
// projectHasInitialized: (projectName: string) => boolean
this._isInitialized = true;
this._isGraphQLConfigMissing = false;
this._logger.info('GraphQL Language Server caches initialized');
}
} catch (err) {
this._handleConfigError({ err });
}
}
private _handleConfigError({ err }: { err: unknown; uri?: string }) {
if (err instanceof ConfigNotFoundError || err instanceof ConfigEmptyError) {
// TODO: obviously this needs to become a map by workspace from uri
// for workspaces support
this._isGraphQLConfigMissing = true;
this._logConfigError(err.message);
} else if (err instanceof ProjectNotFoundError) {
// this is the only case where we don't invalidate config;
// TODO: per-project schema initialization status (PR is almost ready)
this._logConfigError(
'Project not found for this file - make sure that a schema is present in the config file or for the project',
);
} else if (err instanceof ConfigInvalidError) {
this._isGraphQLConfigMissing = true;
this._logConfigError(`Invalid configuration\n${err.message}`);
} else if (err instanceof LoaderNoResultError) {
this._isGraphQLConfigMissing = true;
this._logConfigError(err.message);
return;
} else {
// if it's another kind of error,
// lets just assume the config is missing and
// disable language features
this._isGraphQLConfigMissing = true;
this._logConfigError(
// @ts-expect-error
err?.message ?? err?.toString(),
);
}
}
private _logConfigError(errorMessage: string) {
this._logger.error(
'WARNING: graphql-config error, only highlighting is enabled:\n' +
errorMessage +
`\nfor more information on using 'graphql-config' with 'graphql-language-service-server', \nsee the documentation at ${configDocLink}`,
);
}
private async _isGraphQLConfigFile(uri: string) {
const configMatchers = ['graphql.config', 'graphqlrc', 'graphqlconfig'];
if (this._settings?.load?.fileName?.length) {
configMatchers.push(this._settings.load.fileName);
}
const fileMatch = configMatchers
.filter(Boolean)
.some(v => uri.match(v)?.length);
if (fileMatch) {
return fileMatch;
}
if (uri.match('package.json')?.length) {
try {
const pkgConfig = await readFile(URI.parse(uri).fsPath, 'utf-8');
return Boolean(JSON.parse(pkgConfig)?.graphql);
} catch {}
}
return false;
}
private async _loadConfigOrSkip(uri: string) {
try {
const isGraphQLConfigFile = await this._isGraphQLConfigFile(uri);
if (!this._isInitialized) {
if (this._isGraphQLConfigMissing === true && !isGraphQLConfigFile) {
return true;
}
// don't try to initialize again if we've already tried
// and the graphql config file or package.json entry isn't even there
await this._initializeGraphQLCaches();
return isGraphQLConfigFile;
}
// if it has initialized, but this is another config file change, then let's handle it
if (isGraphQLConfigFile) {
await this._initializeGraphQLCaches();
}
return isGraphQLConfigFile;
} catch (err) {
this._logger.error(String(err));
// return true if it's a graphql config file so we don't treat
// this as a non-config file if it is one
return true;
}
}
public async handleDidOpenOrSaveNotification(
params: DidSaveTextDocumentParams | DidOpenTextDocumentParams,
): Promise<PublishDiagnosticsParams> {
const { textDocument } = params;
const { uri } = textDocument;
/**
* Initialize the LSP server when the first file is opened or saved,
* so that we can access the user settings for config rootDir, etc
*/
const shouldSkip = await this._loadConfigOrSkip(uri);
// if we're loading config or the config is missing or there's an error
// don't do anything else
if (shouldSkip) {
return { uri, diagnostics: [] };
}
// Here, we set the workspace settings in memory,
// and re-initialize the language service when a different
// root path is detected.
// We aren't able to use initialization event for this
// and the config change event is after the fact.
if (!textDocument) {
throw new Error('`textDocument` argument is required.');
}
const diagnostics: Diagnostic[] = [];
if (!this._isInitialized) {
return { uri, diagnostics };
}
try {
const project = this._graphQLCache.getProjectForFile(uri);
if (project) {
const text = 'text' in textDocument && textDocument.text;
// for some reason if i try to tell to not parse empty files, it breaks :shrug:
// i think this is because if the file change is empty, it doesn't get parsed
// TODO: this could be related to a bug in how we are calling didOpenOrSave in our tests
// that doesn't reflect the real runtime behavior
const { contents } = await this._parseAndCacheFile(
uri,
project,
text as string,
);
if (project?.extensions?.languageService?.enableValidation !== false) {
await Promise.all(
contents.map(async ({ query, range }) => {
const results = await this._languageService.getDiagnostics(
query,
uri,
this._isRelayCompatMode(query),
);
if (results && results.length > 0) {
diagnostics.push(
...processDiagnosticsMessage(results, query, range),
);
}
}),
);
}
}
this._logger.log(
JSON.stringify({
type: 'usage',
messageType: 'textDocument/didOpenOrSave',
projectName: project?.name,
fileName: uri,
}),
);
return { uri, diagnostics };
} catch (err) {
this._handleConfigError({ err, uri });
return { uri, diagnostics };
}
}
public async handleDidChangeNotification(
params: DidChangeTextDocumentParams,
): Promise<PublishDiagnosticsParams | null> {
if (
this._isGraphQLConfigMissing ||
!this._isInitialized ||
!this._graphQLCache
) {
return null;
}
// For every `textDocument/didChange` event, keep a cache of textDocuments
// with version information up-to-date, so that the textDocument contents
// may be used during performing language service features,
// e.g. auto-completions.
if (!params?.textDocument?.uri || !params.contentChanges) {
throw new Error(
'`textDocument.uri` and `contentChanges` arguments are required.',
);
}
const { textDocument, contentChanges } = params;
const { uri } = textDocument;
try {
const project = this._graphQLCache.getProjectForFile(uri);
if (!project) {
return { uri, diagnostics: [] };
}
// As `contentChanges` is an array, and we just want the
// latest update to the text, grab the last entry from the array.
// If it's a .js file, try parsing the contents to see if GraphQL queries
// exist. If not found, delete from the cache.
const { contents } = await this._parseAndCacheFile(
uri,
project,
contentChanges.at(-1)!.text,
);
// // If it's a .graphql file, proceed normally and invalidate the cache.
// await this._invalidateCache(textDocument, uri, contents);
const diagnostics: Diagnostic[] = [];
if (project?.extensions?.languageService?.enableValidation !== false) {
// Send the diagnostics onChange as well
try {
await Promise.all(
contents.map(async ({ query, range }) => {
const results = await this._languageService.getDiagnostics(
query,
uri,
this._isRelayCompatMode(query),
);
if (results && results.length > 0) {
diagnostics.push(
...processDiagnosticsMessage(results, query, range),
);
}
// skip diagnostic errors, usually related to parsing incomplete fragments
}),
);
} catch {}
}
this._logger.log(
JSON.stringify({
type: 'usage',
messageType: 'textDocument/didChange',
projectName: project?.name,
fileName: uri,
}),
);
return { uri, diagnostics };
} catch (err) {
this._handleConfigError({ err, uri });
return { uri, diagnostics: [] };
}
}
async handleDidChangeConfiguration(
_params: DidChangeConfigurationParams,
): Promise<DidChangeConfigurationRegistrationOptions> {
await this._initializeGraphQLCaches();
this._logger.log(
JSON.stringify({
type: 'usage',
messageType: 'workspace/didChangeConfiguration',
}),
);
return {};
}
public handleDidCloseNotification(params: DidCloseTextDocumentParams): void {
if (!this._isInitialized) {
return;
}
// For every `textDocument/didClose` event, delete the cached entry.
// This is to keep a low memory usage && switch the source of truth to
// the file on disk.
if (!params?.textDocument) {
throw new Error('`textDocument` is required.');
}
const { textDocument } = params;
const { uri } = textDocument;
if (this._textDocumentCache.has(uri)) {
this._textDocumentCache.delete(uri);
}
const project = this._graphQLCache.getProjectForFile(uri);
this._logger.log(
JSON.stringify({
type: 'usage',
messageType: 'textDocument/didClose',
projectName: project?.name,
fileName: uri,
}),
);
}
public handleShutdownRequest(): void {
this._willShutdown = true;
}
public handleExitNotification(): void {
process.exit(this._willShutdown ? 0 : 1);
}
private validateDocumentAndPosition(params: CompletionParams): void {
if (!params?.textDocument?.uri || !params.position) {
throw new Error(
'`textDocument.uri` and `position` arguments are required.',
);
}
}
public async handleCompletionRequest(
params: CompletionParams,
): Promise<CompletionList> {
if (!this._isInitialized) {
return { items: [], isIncomplete: false };
}
this.validateDocumentAndPosition(params);
const { textDocument, position } = params;
// `textDocument/completion` event takes advantage of the fact that
// `textDocument/didChange` event always fires before, which would have
// updated the cache with the query text from the editor.
// Treat the computed list always complete.
const cachedDocument = this._getCachedDocument(textDocument.uri);
if (!cachedDocument) {
return { items: [], isIncomplete: false };
}
const found = cachedDocument.contents.find(content => {
const currentRange = content.range;
if (currentRange?.containsPosition(toPosition(position))) {
return true;
}
});
// If there is no GraphQL query in this file, return an empty result.
if (!found) {
return { items: [], isIncomplete: false };
}
const { query, range } = found;
if (range) {
position.line -= range.start.line;
}
const result = await this._languageService.getAutocompleteSuggestions(
query,
toPosition(position),
textDocument.uri,
);
const project = this._graphQLCache.getProjectForFile(textDocument.uri);
this._logger.log(
JSON.stringify({
type: 'usage',
messageType: 'textDocument/completion',
projectName: project?.name,
fileName: textDocument.uri,
}),
);
return { items: result, isIncomplete: false };
}
public async handleHoverRequest(
params: TextDocumentPositionParams,
): Promise<Hover> {
if (!this._isInitialized) {
return { contents: [] };
}
this.validateDocumentAndPosition(params);
const { textDocument, position } = params;
const cachedDocument = this._getCachedDocument(textDocument.uri);
if (!cachedDocument) {
return { contents: [] };
}
const found = cachedDocument.contents.find(content => {
const currentRange = content.range;
if (currentRange?.containsPosition(toPosition(position))) {
return true;
}
});
// If there is no GraphQL query in this file, return an empty result.
if (!found) {
return { contents: [] };
}
const { query, range } = found;
if (range) {
position.line -= range.start.line;
}
const result = await this._languageService.getHoverInformation(
query,
toPosition(position),
textDocument.uri,
{ useMarkdown: true },
);
return {
contents: result,
};
}
private async _parseAndCacheFile(
uri: string,
project: GraphQLProjectConfig,
text?: string,
) {
try {
const fileText = text || (await readFile(URI.parse(uri).fsPath, 'utf-8'));
const contents = await this._parser(fileText, uri);
const cachedDocument = this._textDocumentCache.get(uri);
const version = cachedDocument ? cachedDocument.version++ : 0;
await this._invalidateCache({ uri, version }, uri, contents);
await this._updateFragmentDefinition(uri, contents);
await this._updateObjectTypeDefinition(uri, contents, project);
await this._updateSchemaIfChanged(project, uri);
return { contents, version };
} catch {
return { contents: [], version: 0 };
}
}
public async handleWatchedFilesChangedNotification(
params: DidChangeWatchedFilesParams,
): Promise<Array<PublishDiagnosticsParams | undefined> | null> {
const resultsForChanges = Promise.all(
params.changes.map(async (change: FileEvent) => {
const shouldSkip = await this._loadConfigOrSkip(change.uri);
if (shouldSkip) {
return { uri: change.uri, diagnostics: [] };
}
if (
change.type === FileChangeTypeKind.Created ||
change.type === FileChangeTypeKind.Changed
) {
const { uri } = change;
try {
let diagnostics: Diagnostic[] = [];
const project = this._graphQLCache.getProjectForFile(uri);
if (project) {
// Important! Use system file uri not file path here!!!!
const { contents } = await this._parseAndCacheFile(uri, project);
if (
project?.extensions?.languageService?.enableValidation !== false
) {
diagnostics = (
await Promise.all(
contents.map(async ({ query, range }) => {
const results =
await this._languageService.getDiagnostics(
query,
uri,
this._isRelayCompatMode(query),
);
if (results && results.length > 0) {
return processDiagnosticsMessage(results, query, range);
}
return [];
}),
)
).reduce((left, right) => left.concat(right), diagnostics);
}
return { uri, diagnostics };
}
// skip diagnostics errors usually from incomplete files
} catch {}
return { uri, diagnostics: [] };
}
if (change.type === FileChangeTypeKind.Deleted) {
await this._updateFragmentDefinition(change.uri, []);
await this._updateObjectTypeDefinition(change.uri, []);
}
}),
);
this._logger.log(
JSON.stringify({
type: 'usage',
messageType: 'workspace/didChangeWatchedFiles',
files: params.changes.map(change => change.uri),
}),
);
return resultsForChanges;
}
public async handleDefinitionRequest(
params: TextDocumentPositionParams,
_token?: CancellationToken,
): Promise<Array<Location>> {
if (!this._isInitialized) {
return [];
}
if (!params?.textDocument || !params.position) {
throw new Error('`textDocument` and `position` arguments are required.');
}
const { textDocument, position } = params;
const project = this._graphQLCache.getProjectForFile(textDocument.uri);
const cachedDocument = this._getCachedDocument(textDocument.uri);
if (!cachedDocument || !project) {
return [];
}
const found = cachedDocument.contents.find(content => {
const currentRange = content.range;
if (currentRange?.containsPosition(toPosition(position))) {
return true;
}
});
// If there is no GraphQL query in this file, return an empty result.
if (!found) {
return [];
}
const { query, range: parentRange } = found;
if (parentRange) {
position.line -= parentRange.start.line;
}
let result: DefinitionQueryResponse | null = null;
try {
result = await this._languageService.getDefinition(
query,
toPosition(position),
textDocument.uri,
);
} catch {
// these thrown errors end up getting fired before the service is initialized, so lets cool down on that
}
const inlineFragments: string[] = [];
try {
visit(parse(query), {
FragmentDefinition(node: FragmentDefinitionNode) {
inlineFragments.push(node.name.value);
},
});
} catch {}
const locateCommand = project?.extensions?.languageService
?.locateCommand as LocateCommand | undefined;
const formatted = result
? result.definitions.map(res => {
const defRange = res.range as Range;
if (parentRange && res.name) {
const isInline = inlineFragments.includes(res.name);
const isEmbedded = DEFAULT_SUPPORTED_EXTENSIONS.includes(
path.extname(res.path) as SupportedExtensionsEnum,
);
if (isEmbedded || isInline) {
const cachedDoc = this._getCachedDocument(
URI.parse(res.path).toString(),
);
const vOffset = isEmbedded
? cachedDoc?.contents[0].range?.start.line ?? 0
: parentRange.start.line;
defRange.setStart(
(defRange.start.line += vOffset),
defRange.start.character,
);
defRange.setEnd(
(defRange.end.line += vOffset),
defRange.end.character,
);
}
}
if (locateCommand && result && result?.printedName) {
const locateResult = this._getCustomLocateResult(
project,
result,
locateCommand,
);
if (locateResult) {
return locateResult;
}
}
return {
uri: res.path,
range: defRange,
};
})
: [];
this._logger.log(
JSON.stringify({
type: 'usage',
messageType: 'textDocument/definition',
projectName: project?.name,
fileName: textDocument.uri,
}),
);
return formatted;
}
_getCustomLocateResult(
project: GraphQLProjectConfig,
result: DefinitionQueryResponse,
locateCommand: LocateCommand,
) {
if (!result.printedName) {
return null;
}
try {
const locateResult = locateCommand(project.name, result.printedName, {
node: result.node,
type: result.type,
project,
});
if (typeof locateResult === 'string') {
const [uri, startLine = '1', endLine = '1'] = locateResult.split(':');
return {
uri,
range: new Range(
new Position(parseInt(startLine, 10), 0),
new Position(parseInt(endLine, 10), 0),
),
};
}
return locateResult;
} catch (error) {
this._logger.error(
'There was an error executing user defined locateCommand\n\n' +
(error as Error).toString(),
);
return null;
}
}
public async handleDocumentSymbolRequest(
params: DocumentSymbolParams,
): Promise<Array<SymbolInformation>> {
if (!this._isInitialized) {
return [];
}
if (!params?.textDocument) {
throw new Error('`textDocument` argument is required.');
}
const { textDocument } = params;
const cachedDocument = this._getCachedDocument(textDocument.uri);
if (!cachedDocument?.contents[0]) {
return [];
}
if (
this._settings.largeFileThreshold !== undefined &&
this._settings.largeFileThreshold <
cachedDocument.contents[0].query.length
) {
return [];
}
this._logger.log(
JSON.stringify({
type: 'usage',
messageType: 'textDocument/documentSymbol',
fileName: textDocument.uri,
}),
);
return this._languageService.getDocumentSymbols(
cachedDocument.contents[0].query,
textDocument.uri,
);
}
// async handleReferencesRequest(params: ReferenceParams): Promise<Location[]> {
// if (!this._isInitialized) {
// return [];
// }
// if (!params?.textDocument) {
// throw new Error('`textDocument` argument is required.');
// }
// const textDocument = params.textDocument;
// const cachedDocument = this._getCachedDocument(textDocument.uri);
// if (!cachedDocument) {
// throw new Error('A cached document cannot be found.');
// }
// return this._languageService.getReferences(
// cachedDocument.contents[0].query,
// params.position,
// textDocument.uri,
// );
// }
public async handleWorkspaceSymbolRequest(
params: WorkspaceSymbolParams,
): Promise<Array<SymbolInformation>> {
if (!this._isInitialized) {
return [];
}
if (params.query !== '') {
const documents = this._getTextDocuments();
const symbols: SymbolInformation[] = [];
await Promise.all(
documents.map(async ([uri]) => {
const cachedDocument = this._getCachedDocument(uri);
if (!cachedDocument) {
return [];
}
const docSymbols = await this._languageService.getDocumentSymbols(
cachedDocument.contents[0].query,
uri,
);
symbols.push(...docSymbols);
}),
);
return symbols.filter(symbol => symbol?.name?.includes(params.query));
}
return [];
}
private _getTextDocuments() {
return Array.from(this._textDocumentCache);
}
private async _cacheSchemaText(
uri: string,
text: string,
version: number,
project?: GraphQLProjectConfig,
) {
try {
const contents = await this._parser(text, uri);
if (contents.length > 0) {
await this._invalidateCache({ version, uri }, uri, contents);
await this._updateObjectTypeDefinition(uri, contents, project);
}
} catch (err) {
this._logger.error(String(err));
}
}
private async _cacheSchemaFile(
fileUri: UnnormalizedTypeDefPointer,
project: GraphQLProjectConfig,
) {
try {
// const parsedUri = URI.file(fileUri.toString());
// @ts-expect-error
const matches = await glob(fileUri, {
cwd: project.dirpath,
absolute: true,
});
const uri = matches[0];
let version = 1;
if (uri) {
const schemaUri = URI.file(uri).toString();
const schemaDocument = this._getCachedDocument(schemaUri);
if (schemaDocument) {
version = schemaDocument.version++;
}
const schemaText = await readFile(uri, 'utf-8');
await this._cacheSchemaText(schemaUri, schemaText, version);
}
} catch (err) {
this._logger.error(String(err));
}
}
private _getTmpProjectPath(
project: GraphQLProjectConfig,
prependWithProtocol = true,
appendPath?: string,
) {
const baseDir = this._graphQLCache.getGraphQLConfig().dirpath;
const workspaceName = path.basename(baseDir);
const basePath = path.join(this._tmpDirBase, workspaceName);
let projectTmpPath = path.join(basePath, 'projects', project.name);
if (!existsSync(projectTmpPath)) {
mkdirSync(projectTmpPath, {
recursive: true,
});
}
if (appendPath) {
projectTmpPath = path.join(projectTmpPath, appendPath);
}
if (prependWithProtocol) {
return URI.file(path.resolve(projectTmpPath)).toString();
}
return path.resolve(projectTmpPath);
}
private getCachedSchemaSettings(project: GraphQLProjectConfig) {
const config = project?.extensions?.languageService;
let cacheSchemaFileForLookup = true;
let schemaCacheTTL = 1000 * 30;
if (
config?.cacheSchemaFileForLookup === false ||
this?._settings?.cacheSchemaFileForLookup === false
) {
cacheSchemaFileForLookup = false;
}
// nullish coalescing allows 0 to be a valid value here
if (config?.schemaCacheTTL) {
schemaCacheTTL = config.schemaCacheTTL;
}
if (this?._settings?.schemaCacheTTL) {
schemaCacheTTL = this._settings.schemaCacheTTL;
}
return { cacheSchemaFileForLookup, schemaCacheTTL };
}
private async _cacheSchemaFilesForProject(project: GraphQLProjectConfig) {
// const config = project?.extensions?.languageService;
/**
* By default, we look for schema definitions in SDL files
*
* with the opt-in feature `cacheSchemaOutputFileForLookup` enabled,
* the resultant `graphql-config` .getSchema() schema output will be cached
* locally and available as a single file for definition lookup and peek
*
* this is helpful when your `graphql-config` `schema` input is:
* - a remote or local URL
* - compiled from graphql files and code sources
* - otherwise where you don't have schema SDL in the codebase or don't want to use it for lookup
*
* it is disabled by default
*/
const { cacheSchemaFileForLookup } = this.getCachedSchemaSettings(project);
const unwrappedSchema = unwrapProjectSchema(project);
// only local schema lookups if all of the schema entries are local files
const sdlOnly = isProjectSDLOnly(unwrappedSchema);
// const uri = this._getTmpProjectPath(
// project,
// true,
// 'generated-schema.graphql',
// );
// const fsPath = this._getTmpProjectPath(
// project,
// false,
// 'generated-schema.graphql',
// );
// invalidate the cache for the generated schema file
// whether or not the user will continue using this feature
// because sdlOnly needs this file to be removed as well if the user is switching schemas
// this._textDocumentCache.delete(uri);
// skip exceptions if the file doesn't exist
try {
// await rm(fsPath, { force: true });
} catch {}
// if we are caching the config schema, and it isn't a .graphql file, cache it
if (cacheSchemaFileForLookup && !sdlOnly) {
await this.cacheConfigSchemaFile(project);
} else if (sdlOnly) {
await Promise.all(
unwrappedSchema.map(async schemaEntry =>
this._cacheSchemaFile(schemaEntry, project),
),
);
}
}
/**
* Cache the schema as represented by graphql-config, with extensions
* from GraphQLCache.getSchema()
* @param project {GraphQLProjectConfig}
*/
private async cacheConfigSchemaFile(project: GraphQLProjectConfig) {
try {
const schema = await this._graphQLCache.getSchema(project.name);
if (schema) {
let schemaText = printSchema(schema);
// file:// protocol path
const uri = this._getTmpProjectPath(
project,
true,
'generated-schema.graphql',
);
// no file:// protocol for fs.writeFileSync()
const fsPath = this._getTmpProjectPath(
project,
false,
'generated-schema.graphql',
);
schemaText = `# This is an automatically generated representation of your schema.\n# Any changes to this file will be overwritten and will not be\n# reflected in the resulting GraphQL schema\n\n${schemaText}`;
const cachedSchemaDoc = this._getCachedDocument(uri);
this._graphQLCache._schemaMap.set(project.name, schema);
try {
await mkdir(path.dirname(fsPath), { recursive: true });
} catch {}
if (!cachedSchemaDoc) {
await writeFile(fsPath, schemaText, {
encoding: 'utf-8',
});
await this._cacheSchemaText(uri, schemaText, 0, project);
}
// do we have a change in the getSchema result? if so, update schema cache
if (cachedSchemaDoc) {
await writeFile(fsPath, schemaText, 'utf-8');
await this._cacheSchemaText(
uri,
schemaText,
cachedSchemaDoc.version++,
project,
);
}
}
} catch (err) {
this._logger.error(String(err));
}
}
/**
* Pre-cache all documents for a project.
*
* TODO: Maybe make this optional, where only schema needs to be pre-cached.
*
* @param project {GraphQLProjectConfig}
*/
private async _cacheDocumentFilesforProject(project: GraphQLProjectConfig) {
try {
const documents = await project.getDocuments();
const documentLocations = new Set(
documents
.filter(doc => doc.location && doc.rawSDL)
.map(doc => doc.location!),
);
return Promise.all(
Array.from(documentLocations).map(async loc => {
let filePath = loc;
if (!path.isAbsolute(filePath)) {
filePath = path.join(project.dirpath, loc);
}
// build full system URI path with protocol
const uri = URI.file(filePath).toString();
const fileContent = await readFile(filePath, 'utf-8');
// I would use the already existing graphql-config AST, but there are a few reasons we can't yet
const contents = await this._parser(fileContent, uri);
if (!contents[0]?.query) {
return;
}
await this._updateObjectTypeDefinition(uri, contents);
await this._updateFragmentDefinition(uri, contents);
await this._invalidateCache({ version: 1, uri }, uri, contents);
}),
);
} catch (err) {
this._logger.error(
`invalid/unknown file in graphql config documents entry:\n '${project.documents}'`,
);
this._logger.error(String(err));
}
}
/**
* This should only be run on initialize() really.
* Caching all the document files upfront could be expensive.
* @param config {GraphQLConfig}
*/
private async _cacheAllProjectFiles(config: GraphQLConfig) {
if (config?.projects) {
return Promise.all(
Object.keys(config.projects).map(async projectName => {
const project = config.getProject(projectName);
await this._cacheSchemaFilesForProject(project);
if (project.documents?.length) {
await this._cacheDocumentFilesforProject(project);
} else {
this._logger.warn(
[
`No 'documents' config found for project: ${projectName}.`,
'Fragments and query documents cannot be detected.',
'LSP server will only perform some partial validation and SDL features.',
].join('\n'),
);
}
}),
);
}
}
_isRelayCompatMode(query: string): boolean {
return (
query.includes('RelayCompat') || query.includes('react-relay/compat')
);
}
private async _updateFragmentDefinition(
uri: Uri,
contents: CachedContent[],
): Promise<void> {
const project = this._graphQLCache.getProjectForFile(uri);
if (project) {
const cacheKey = this._graphQLCache._cacheKeyForProject(project);
await this._graphQLCache.updateFragmentDefinition(
cacheKey,
uri,
contents,
);
}
}
private async _updateSchemaIfChanged(
project: GraphQLProjectConfig,
uri: Uri,
): Promise<void> {
await Promise.all(
unwrapProjectSchema(project).map(async schema => {
const schemaFilePath = path.resolve(project.dirpath, schema);
const uriFilePath = URI.parse(uri).fsPath;
if (uriFilePath === schemaFilePath) {
try {
const file = await readFile(schemaFilePath, 'utf-8');
// only invalidate the schema cache if we can actually parse the file
// otherwise, leave the last valid one in place
parse(file, { noLocation: true });
this._graphQLCache.invalidateSchemaCacheForProject(project);
} catch {}
}
}),
);
}
private async _updateObjectTypeDefinition(
uri: Uri,
contents: CachedContent[],
project?: GraphQLProjectConfig,
): Promise<void> {
const resolvedProject =
project ?? (await this._graphQLCache.getProjectForFile(uri));
if (resolvedProject) {
const cacheKey = this._graphQLCache._cacheKeyForProject(resolvedProject);
await this._graphQLCache.updateObjectTypeDefinition(
cacheKey,
uri,
contents,
);
}
}
private _getCachedDocument(uri: string): CachedDocumentType | null {
if (this._textDocumentCache.has(uri)) {
const cachedDocument = this._textDocumentCache.get(uri);
if (cachedDocument) {
return cachedDocument;
}
}
return null;
}
private async _invalidateCache(
textDocument: VersionedTextDocumentIdentifier,
uri: Uri,
contents: CachedContent[],
): Promise<Map<string, CachedDocumentType> | null> {
if (this._textDocumentCache.has(uri)) {
const cachedDocument = this._textDocumentCache.get(uri);
if (
cachedDocument &&
textDocument?.version &&
cachedDocument.version < textDocument.version
) {
// Current server capabilities specify the full sync of the contents.
// Therefore always overwrite the entire content.
return this._textDocumentCache.set(uri, {
version: textDocument.version,
contents,
});
}
}
return this._textDocumentCache.set(uri, {
version: textDocument.version ?? 0,
contents,
});
}
}
export function processDiagnosticsMessage(
results: Diagnostic[],
query: string,
range: RangeType | null,
): Diagnostic[] {
const queryLines = query.split('\n');
const totalLines = queryLines.length;
const lastLineLength = queryLines[totalLines - 1].length;
const lastCharacterPosition = new Position(totalLines, lastLineLength);
const processedResults = results.filter(diagnostic =>
// @ts-ignore
diagnostic.range.end.lessThanOrEqualTo(lastCharacterPosition),
);
if (range) {
const offset = range.start;
return processedResults.map(diagnostic => ({
...diagnostic,
range: new Range(
new Position(
diagnostic.range.start.line + offset.line,
diagnostic.range.start.character,
),
new Position(
diagnostic.range.end.line + offset.line,
diagnostic.range.end.character,
),
),
}));
}
return processedResults;
}