apollo-language-server
Version:
A language server for Apollo GraphQL projects
356 lines (292 loc) • 9.29 kB
text/typescript
import { extname } from "path";
import { lstatSync, readFileSync } from "fs";
import URI from "vscode-uri";
import {
TypeSystemDefinitionNode,
isTypeSystemDefinitionNode,
TypeSystemExtensionNode,
isTypeSystemExtensionNode,
DefinitionNode,
GraphQLSchema,
Kind,
} from "graphql";
import {
TextDocument,
NotificationHandler,
PublishDiagnosticsParams,
Position,
} from "vscode-languageserver";
import { GraphQLDocument, extractGraphQLDocuments } from "../document";
import { LoadingHandler } from "../loadingHandler";
import { FileSet } from "../fileSet";
import { ApolloConfig, keyEnvVar } from "../config";
import {
schemaProviderFromConfig,
GraphQLSchemaProvider,
SchemaResolveConfig,
} from "../providers/schema";
import { ApolloEngineClient, ClientIdentity } from "../engine";
export type DocumentUri = string;
const fileAssociations: { [extension: string]: string } = {
".graphql": "graphql",
".gql": "graphql",
".js": "javascript",
".ts": "typescript",
".jsx": "javascriptreact",
".tsx": "typescriptreact",
".vue": "vue",
".py": "python",
".rb": "ruby",
".dart": "dart",
".re": "reason",
".ex": "elixir",
".exs": "elixir",
};
export interface GraphQLProjectConfig {
clientIdentity?: ClientIdentity;
config: ApolloConfig;
fileSet: FileSet;
loadingHandler: LoadingHandler;
}
export interface TypeStats {
service?: number;
client?: number;
total?: number;
}
export interface ProjectStats {
type: string;
loaded: boolean;
serviceId?: string;
types?: TypeStats;
tag?: string;
lastFetch?: number;
}
export abstract class GraphQLProject implements GraphQLSchemaProvider {
public schemaProvider: GraphQLSchemaProvider;
protected _onDiagnostics?: NotificationHandler<PublishDiagnosticsParams>;
private _isReady: boolean;
private readyPromise: Promise<void>;
protected engineClient?: ApolloEngineClient;
private needsValidation = false;
protected documentsByFile: Map<DocumentUri, GraphQLDocument[]> = new Map();
public config: ApolloConfig;
public schema?: GraphQLSchema;
private fileSet: FileSet;
protected loadingHandler: LoadingHandler;
protected lastLoadDate?: number;
constructor({
config,
fileSet,
loadingHandler,
clientIdentity,
}: GraphQLProjectConfig) {
this.config = config;
this.fileSet = fileSet;
this.loadingHandler = loadingHandler;
this.schemaProvider = schemaProviderFromConfig(config, clientIdentity);
const { engine } = config;
if (engine.apiKey) {
this.engineClient = new ApolloEngineClient(
engine.apiKey!,
engine.endpoint,
clientIdentity
);
}
this._isReady = false;
// FIXME: Instead of `Promise.all`, we should catch individual promise rejections
// so we can show multiple errors.
this.readyPromise = Promise.all(this.initialize())
.then(() => {
this._isReady = true;
})
.catch((error) => {
console.error(error);
this.loadingHandler.showError(
`Error initializing Apollo GraphQL project "${this.displayName}": ${error}`
);
});
}
abstract get displayName(): string;
protected abstract initialize(): Promise<void>[];
abstract getProjectStats(): ProjectStats;
get isReady(): boolean {
return this._isReady;
}
get engine(): ApolloEngineClient {
// handle error states for missing engine config
// all in the same place :tada:
if (!this.engineClient) {
throw new Error(`Unable to find ${keyEnvVar}`);
}
return this.engineClient!;
}
get whenReady(): Promise<void> {
return this.readyPromise;
}
public updateConfig(config: ApolloConfig) {
this.config = config;
return this.initialize();
}
public resolveSchema(config: SchemaResolveConfig): Promise<GraphQLSchema> {
this.lastLoadDate = +new Date();
return this.schemaProvider.resolveSchema(config);
}
public resolveFederatedServiceSDL() {
return this.schemaProvider.resolveFederatedServiceSDL();
}
public onSchemaChange(handler: NotificationHandler<GraphQLSchema>) {
this.lastLoadDate = +new Date();
return this.schemaProvider.onSchemaChange(handler);
}
onDiagnostics(handler: NotificationHandler<PublishDiagnosticsParams>) {
this._onDiagnostics = handler;
}
includesFile(uri: DocumentUri) {
return this.fileSet.includesFile(uri);
}
async scanAllIncludedFiles() {
await this.loadingHandler.handle(
`Loading queries for ${this.displayName}`,
(async () => {
for (const filePath of this.fileSet.allFiles()) {
const uri = URI.file(filePath).toString();
// If we already have query documents for this file, that means it was either
// opened or changed before we got a chance to read it.
if (this.documentsByFile.has(uri)) continue;
this.fileDidChange(uri);
}
})()
);
}
fileDidChange(uri: DocumentUri) {
const filePath = URI.parse(uri).fsPath;
const extension = extname(filePath);
const languageId = fileAssociations[extension];
// Don't process files of an unsupported filetype
if (!languageId) return;
// Don't process directories. Directories might be named like files so
// we have to explicitly check.
if (!lstatSync(filePath).isFile()) return;
const contents = readFileSync(filePath, "utf8");
const document = TextDocument.create(uri, languageId, -1, contents);
this.documentDidChange(document);
}
fileWasDeleted(uri: DocumentUri) {
this.removeGraphQLDocumentsFor(uri);
this.checkForDuplicateOperations();
}
documentDidChange(document: TextDocument) {
const documents = extractGraphQLDocuments(
document,
this.config.client && this.config.client.tagName
);
if (documents) {
this.documentsByFile.set(document.uri, documents);
this.invalidate();
} else {
this.removeGraphQLDocumentsFor(document.uri);
}
this.checkForDuplicateOperations();
}
checkForDuplicateOperations(): void {
const operations = Object.create(null);
for (const document of this.documents) {
if (!document.ast) continue;
for (const definition of document.ast.definitions) {
if (definition.kind === Kind.OPERATION_DEFINITION && definition.name) {
if (operations[definition.name.value]) {
throw new Error(
`️️There are multiple definitions for the \`${definition.name.value}\` operation. Please rename or remove all operations with the duplicated name before continuing.`
);
}
operations[definition.name.value] = definition;
}
}
}
}
private removeGraphQLDocumentsFor(uri: DocumentUri) {
if (this.documentsByFile.has(uri)) {
this.documentsByFile.delete(uri);
if (this._onDiagnostics) {
this._onDiagnostics({ uri: uri, diagnostics: [] });
}
this.invalidate();
}
}
protected invalidate() {
if (!this.needsValidation && this.isReady) {
setTimeout(() => {
this.validateIfNeeded();
}, 0);
this.needsValidation = true;
}
}
private validateIfNeeded() {
if (!this.needsValidation || !this.isReady) return;
this.validate();
this.needsValidation = false;
}
abstract validate(): void;
clearAllDiagnostics() {
if (!this._onDiagnostics) return;
for (const uri of this.documentsByFile.keys()) {
this._onDiagnostics({ uri, diagnostics: [] });
}
}
documentsAt(uri: DocumentUri): GraphQLDocument[] | undefined {
return this.documentsByFile.get(uri);
}
documentAt(
uri: DocumentUri,
position: Position
): GraphQLDocument | undefined {
const queryDocuments = this.documentsByFile.get(uri);
if (!queryDocuments) return undefined;
return queryDocuments.find((document) =>
document.containsPosition(position)
);
}
get documents(): GraphQLDocument[] {
const documents: GraphQLDocument[] = [];
for (const documentsForFile of this.documentsByFile.values()) {
documents.push(...documentsForFile);
}
return documents;
}
get definitions(): DefinitionNode[] {
const definitions = [];
for (const document of this.documents) {
if (!document.ast) continue;
definitions.push(...document.ast.definitions);
}
return definitions;
}
definitionsAt(uri: DocumentUri): DefinitionNode[] {
const documents = this.documentsAt(uri);
if (!documents) return [];
const definitions = [];
for (const document of documents) {
if (!document.ast) continue;
definitions.push(...document.ast.definitions);
}
return definitions;
}
get typeSystemDefinitionsAndExtensions(): (
| TypeSystemDefinitionNode
| TypeSystemExtensionNode
)[] {
const definitionsAndExtensions = [];
for (const document of this.documents) {
if (!document.ast) continue;
for (const definition of document.ast.definitions) {
if (
isTypeSystemDefinitionNode(definition) ||
isTypeSystemExtensionNode(definition)
) {
definitionsAndExtensions.push(definition);
}
}
}
return definitionsAndExtensions;
}
}