diff --git a/.eslintignore b/.eslintignore index f4acfa79..e3b05003 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,3 @@ !.eslintrc.js **/node_modules/** -**/VendorLib/** **/flow-typed/** -pkg/nuclide-external-interfaces/1.0/simple-text-buffer.js diff --git a/README.md b/README.md index f5b76cca..22ae4c27 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,13 @@ _This is currently in technical preview. We welcome your feedback and suggestions._ -GraphQL Language Service provides an interface for building GraphQL language services for IDEs. Currently supported features include: -- Diagnostics (GraphQL syntax linting/validations) -- Autocomplete suggestions +GraphQL Language Service provides an interface for building GraphQL language service for IDEs. + +A subset of supported features of GraphQL language service and GraphQL language server implementation are both specification-compliant to [Microsoft's Language Server Protocol](https://github.com/Microsoft/language-server-protocol), and will be developed to fully support the specification in the future. + +Currently supported features include: +- Diagnostics (GraphQL syntax linting/validations) (**spec-compliant**) +- Autocomplete suggestions (**spec-compliant**) - Hyperlink to fragment definitions - Outline view support for queries @@ -61,31 +65,42 @@ The node executable contains several commands: `server` and a command-line langu Improving this list is a work-in-progress. ``` -Usage: graphql - +GraphQL Language Service Command-Line Interface. +Usage: bin/graphql.js [-h | --help] - [-c | --config] {configPath} + [-c | --configDir] {configDir} [-t | --text] {textBuffer} [-f | --file] {filePath} [-s | --schema] {schemaPath} + Options: - -h, --help Show help [boolean] - -c, --config GraphQL Config file path (.graphqlrc). - Will look for the nearest .graphqlrc file if omitted. + -h, --help Show help [boolean] + -t, --text Text buffer to perform GraphQL diagnostics on. + Will defer to --file option if omitted. + This option is always honored over --file option. [string] - -t, --text Text buffer to perform GraphQL lint on. - Will defer to --file option if omitted. - This option is always honored over --file option. + -f, --file File path to perform GraphQL diagnostics on. + Will be ignored if --text option is supplied. [string] - -f, --file File path to perform GraphQL lint on. - Will be ignored if --text option is supplied. + --row A row number from the cursor location for GraphQL + autocomplete suggestions. + If omitted, the last row number will be used. + [number] + --column A column number from the cursor location for GraphQL + autocomplete suggestions. + If omitted, the last column number will be used. + [number] + -c, --configDir A directory path where .graphqlrc configuration object is + Walks up the directory tree from the provided config + directory, or the current working directory, until + .graphqlrc is found or the root directory is found. [string] - -s, --schema a path to schema DSL file + -s, --schemaPath a path to schema DSL file [string] At least one command is required. -Commands: "server, lint, autocomplete, outline" +Commands: "server, validate, autocomplete, outline" ``` ## Architectural Overview @@ -112,84 +127,14 @@ The IDE server should manage the lifecycle of the GraphQL server. Ideally, the I ### Server Interface -The server sends/receives RPC messages to/from the IDE server to perform language service features. The details for the RPC message format are described below: - -``` -/** - * The JSON message sent from the IDE server should have the following structure: - * { - * protocol: 'graphql-protocol', - * id: number, - * method: string, // one of the function names below, e.g. `getDiagnostics` - * args: { - * query?: string, - * position?: Point, - * filePath?: Uri, - * } - * } - */ -// Diagnostics (lint/validation) -export type GraphQLDiagnosticMessage = { - name: string, - type: string, - text: string, - range: atom$Range, - filePath: string, -}; - -export function getDiagnostics( - query: string, - filePath: Uri, -) : Promise> { - throw new Error('RPC stub'); -} - -// Autocomplete Suggestions (typeahead) -export type GraphQLAutocompleteSuggestionType = { - text: string, - typeName: ?string, - description: ?string, -}; - -export function getAutocompleteSuggestions( - query: string, - position: atom$Point, - filePath: Uri, -) : Promise> { - throw new Error('RPC stub'); -} +GraphQL Language Server uses [JSON-RPC](http://www.jsonrpc.org/specification) to communicate with the IDE servers to perform language service features. The language server currently supports two communication transports: Stream (stdio) and IPC. For IPC transport, the reference guide to be used for development is [the language server protocol](https://github.com/Microsoft/language-server-protocol/blob/master/protocol.md) documentation. -// Definitions (hyperlink) -export type Definition = { - path: Uri, - position: Point, - range?: Range, - id?: string, - name?: string, - language: string, - projectRoot?: Uri, -}; - -export type DefinitionQueryResult = { - queryRange: Array, - definitions: Array, -}; - -export function getDefinition( - query: string, - position: atom$Point, - filePath: Uri, -): Promise { - throw new Error('RPC stub'); -} +For each transports, there is a slight difference between both JSON message format, especially in how the methods to be invoked are defined - below are the currently supported methods for each transports (will be updated as progresses are made): -// Outline view -export function getOutline(query: string): Promise { - throw new Error('RPC stub'); -} - -// Disconnect signal - gracefully terminate the connection on IDE exit -export function disconnect(): void { - throw new Error('RPC stub'); -} -``` +| | Stream | IPC | +| -------------------:|------------------------------|-----------------------------------| +| Diagnostics | `getDiagnostics` | `textDocument/publishDiagnostics` | +| Autocompletion | `getAutocompleteSuggestions` | `textDocument/completion` | +| Outline | `getOutline` | Not supported yet | +| Go-to definition | `getDefinition` | Not supported yet | +| File Events | Not supported yet | `didOpen/didClose/didSave/didChange` events | diff --git a/src/cli.js b/src/cli.js index 6ade2831..c32ff505 100644 --- a/src/cli.js +++ b/src/cli.js @@ -17,7 +17,7 @@ const {argv} = yargs 'GraphQL Language Service Command-Line Interface.\n' + 'Usage: $0 \n' + ' [-h | --help]\n' + - ' [-c | --config] {configPath}\n' + + ' [-c | --configDir] {configDir}\n' + ' [-t | --text] {textBuffer}\n' + ' [-f | --file] {filePath}\n' + ' [-s | --schema] {schemaPath}\n', @@ -52,6 +52,14 @@ const {argv} = yargs 'If omitted, the last column number will be used.\n', type: 'number', }) + .option('c', { + alias: 'configDir', + describe: 'A directory path where .graphqlrc configuration object is\n' + + 'Walks up the directory tree from the provided config directory, or ' + + 'the current working directory, until .graphqlrc is found or ' + + 'the root directory is found.\n', + type: 'string', + }) .option('s', { alias: 'schemaPath', describe: 'a path to schema DSL file\n', @@ -62,7 +70,7 @@ const command = argv._.pop(); switch (command) { case 'server': - startServer(argv.config.trim()); + startServer(argv.configDir); break; default: client(command, argv); diff --git a/src/client.js b/src/client.js index 82944bc6..70119b8d 100644 --- a/src/client.js +++ b/src/client.js @@ -15,7 +15,7 @@ import fs from 'fs'; import {buildSchema, buildClientSchema} from 'graphql'; import path from 'path'; -import {Point} from './utils/Range'; +import {Position} from './utils/Range'; import { getAutocompleteSuggestions, } from './interfaces/getAutocompleteSuggestions'; @@ -54,7 +54,7 @@ export default function main(command: string, argv: Object): void { const lines = text.split('\n'); const row = argv.row || lines.length - 1; const column = argv.column || lines[lines.length - 1].length; - const point = new Point(row, column); + const point = new Position(row, column); exitCode = _getAutocompleteSuggestions(text, point, schemaPath); break; case 'outline': @@ -72,7 +72,7 @@ export default function main(command: string, argv: Object): void { function _getAutocompleteSuggestions( queryText: string, - point: Point, + point: Position, schemaPath: string, ): EXIT_CODE { invariant( @@ -104,7 +104,7 @@ function _getDiagnostics( // `schema` is not strictly requied as GraphQL diagnostics may still notify // whether the query text is syntactically valid. const schema = schemaPath ? generateSchema(schemaPath) : null; - const resultArray = getDiagnostics(filePath, queryText, schema); + const resultArray = getDiagnostics(queryText, schema); const resultObject = resultArray.reduce((prev, cur, index) => { prev[index] = cur; return prev; diff --git a/src/config/GraphQLConfig.js b/src/config/GraphQLConfig.js index d6ad4bde..0762a456 100644 --- a/src/config/GraphQLConfig.js +++ b/src/config/GraphQLConfig.js @@ -17,6 +17,26 @@ const CONFIG_LIST_NAME = 'build-configs'; const SCHEMA_PATH = 'schema-file'; const CUSTOM_VALIDATION_RULES_MODULE_PATH = 'custom-validation-rules'; +/** + * Finds a .graphqlrc configuration file, and returns null if not found. + * If the file isn't present in the provided directory path, walk up the + * directory tree until the file is found or it reaches the root directory. + */ +export async function findGraphQLConfigDir(dirPath: Uri): Promise { + let currentPath = path.resolve(dirPath); + let filePath; + while (currentPath.length > 1) { + filePath = path.join(currentPath, '.graphqlrc'); + if (fs.existsSync(filePath)) { + break; + } + + currentPath = path.dirname(currentPath); + } + + return filePath ? currentPath : null; +} + export async function getGraphQLConfig(configDir: Uri): Promise { const rawGraphQLConfig = await new Promise((resolve, reject) => fs.readFile( diff --git a/src/interfaces/GraphQLLanguageService.js b/src/interfaces/GraphQLLanguageService.js index c2075c9c..9f1e73b2 100644 --- a/src/interfaces/GraphQLLanguageService.js +++ b/src/interfaces/GraphQLLanguageService.js @@ -12,12 +12,12 @@ import type {ASTNode} from 'graphql/language'; import type {GraphQLCache} from '../server/GraphQLCache'; import type {GraphQLRC, GraphQLConfig} from '../config/GraphQLConfig'; import type { - AutocompleteSuggestionType, + CompletionItem, DefinitionQueryResult, - DiagnosticType, + Diagnostic, Uri, } from '../types/Types'; -import type {Point} from '../utils/Range'; +import type {Position} from '../utils/Range'; import { FRAGMENT_SPREAD, @@ -32,7 +32,7 @@ import { getDefinitionQueryResultForFragmentSpread, getDefinitionQueryResultForDefinitionNode, } from './getDefinition'; -import {getASTNodeAtPoint} from '../utils/getASTNodeAtPoint'; +import {getASTNodeAtPosition} from '../utils/getASTNodeAtPosition'; export class GraphQLLanguageService { _graphQLCache: GraphQLCache; @@ -45,10 +45,10 @@ export class GraphQLLanguageService { async getDiagnostics( query: string, - filePath: Uri, - ): Promise> { + uri: Uri, + ): Promise> { let source = query; - const graphQLConfig = this._graphQLRC.getConfigByFilePath(filePath); + const graphQLConfig = this._graphQLRC.getConfigByFilePath(uri); // If there's a matching config, proceed to prepare to run validation let schema; let customRules; @@ -80,14 +80,14 @@ export class GraphQLLanguageService { } } - return getDiagnosticsImpl(filePath, source, schema, customRules); + return getDiagnosticsImpl(source, schema, customRules); } async getAutocompleteSuggestions( query: string, - position: Point, + position: Position, filePath: Uri, - ): Promise> { + ): Promise> { const graphQLConfig = this._graphQLRC.getConfigByFilePath(filePath); let schema; if (graphQLConfig && graphQLConfig.getSchemaPath()) { @@ -102,7 +102,7 @@ export class GraphQLLanguageService { async getDefinition( query: string, - position: Point, + position: Position, filePath: Uri, ): Promise { const graphQLConfig = this._graphQLRC.getConfigByFilePath(filePath); @@ -117,7 +117,7 @@ export class GraphQLLanguageService { return null; } - const node = getASTNodeAtPoint(query, ast, position); + const node = getASTNodeAtPosition(query, ast, position); switch (node ? node.kind : null) { case FRAGMENT_SPREAD: return this._getDefinitionForFragmentSpread( diff --git a/src/interfaces/__tests__/getAutocompleteSuggestions-test.js b/src/interfaces/__tests__/getAutocompleteSuggestions-test.js index 9873efdc..0c8bc7f7 100644 --- a/src/interfaces/__tests__/getAutocompleteSuggestions-test.js +++ b/src/interfaces/__tests__/getAutocompleteSuggestions-test.js @@ -8,7 +8,7 @@ * @flow */ -import type {AutocompleteSuggestionType} from '../../types/Types'; +import type {CompletionItem} from '../../types/Types'; import {expect} from 'chai'; import {beforeEach, describe, it} from 'mocha'; @@ -17,7 +17,7 @@ import {getNamedType} from 'graphql/type'; import {buildSchema} from 'graphql/utilities'; import path from 'path'; -import {Point} from '../../utils/Range'; +import {Position} from '../../utils/Range'; import { getAutocompleteSuggestions, } from '../getAutocompleteSuggestions'; @@ -35,16 +35,16 @@ describe('getAutocompleteSuggestions', () => { // Returns a soreted autocomplete suggestions in an increasing order. function testSuggestions( query: string, - point: Point, - ): Array { + point: Position, + ): Array { return getAutocompleteSuggestions(schema, query, point).filter( - field => !['__schema', '__type'].some(name => name === field.text), + field => !['__schema', '__type'].some(name => name === field.label), ).sort( - (a, b) => a.text.localeCompare(b.text), + (a, b) => a.label.localeCompare(b.label), ).map(suggestion => { - const response = {text: suggestion.text}; - if (suggestion.type) { - Object.assign(response, {type: getNamedType(suggestion.type).name}); + const response = {label: suggestion.label}; + if (suggestion.detail) { + Object.assign(response, {detail: getNamedType(suggestion.detail).name}); } return response; }); @@ -52,152 +52,152 @@ describe('getAutocompleteSuggestions', () => { it('provides correct initial keywords', () => { expect( - testSuggestions('', new Point(0, 0)), + testSuggestions('', new Position(0, 0)), ).to.deep.equal([ - {text: '{'}, - {text: 'fragment'}, - {text: 'mutation'}, - {text: 'query'}, - {text: 'subscription'}, + {label: '{'}, + {label: 'fragment'}, + {label: 'mutation'}, + {label: 'query'}, + {label: 'subscription'}, ]); expect( - testSuggestions('q', new Point(0, 1)), + testSuggestions('q', new Position(0, 1)), ).to.deep.equal([ - {text: '{'}, - {text: 'query'}, + {label: '{'}, + {label: 'query'}, ]); }); it('provides correct suggestions at where the cursor is', () => { // Below should provide initial keywords expect( - testSuggestions(' {}', new Point(0, 0)), + testSuggestions(' {}', new Position(0, 0)), ).to.deep.equal([ - {text: '{'}, - {text: 'fragment'}, - {text: 'mutation'}, - {text: 'query'}, - {text: 'subscription'}, + {label: '{'}, + {label: 'fragment'}, + {label: 'mutation'}, + {label: 'query'}, + {label: 'subscription'}, ]); // Below should provide root field names expect( - testSuggestions(' {}', new Point(0, 2)), + testSuggestions(' {}', new Position(0, 2)), ).to.deep.equal([ - {text: 'droid', type: 'Droid'}, - {text: 'hero', type: 'Character'}, - {text: 'human', type: 'Human'}, - {text: 'inputTypeTest', type: 'TestType'}, + {label: 'droid', detail: 'Droid'}, + {label: 'hero', detail: 'Character'}, + {label: 'human', detail: 'Human'}, + {label: 'inputTypeTest', detail: 'TestType'}, ]); }); it('provides correct field name suggestions', () => { - const result = testSuggestions('{ ', new Point(0, 2)); + const result = testSuggestions('{ ', new Position(0, 2)); expect(result).to.deep.equal([ - {text: 'droid', type: 'Droid'}, - {text: 'hero', type: 'Character'}, - {text: 'human', type: 'Human'}, - {text: 'inputTypeTest', type: 'TestType'}, + {label: 'droid', detail: 'Droid'}, + {label: 'hero', detail: 'Character'}, + {label: 'human', detail: 'Human'}, + {label: 'inputTypeTest', detail: 'TestType'}, ]); }); it('provides correct field name suggestions after filtered', () => { - const result = testSuggestions('{ h ', new Point(0, 3)); + const result = testSuggestions('{ h ', new Position(0, 3)); expect(result).to.deep.equal([ - {text: 'hero', type: 'Character'}, - {text: 'human', type: 'Human'}, + {label: 'hero', detail: 'Character'}, + {label: 'human', detail: 'Human'}, ]); }); it('provides correct field name suggestions with alias', () => { const result = testSuggestions( '{ alias: human(id: "1") { ', - new Point(0, 26), + new Position(0, 26), ); expect(result).to.deep.equal([ - {text: 'appearsIn', type: 'Episode'}, - {text: 'friends', type: 'Character'}, - {text: 'id', type: 'String'}, - {text: 'name', type: 'String'}, - {text: 'secretBackstory', type: 'String'}, + {label: 'appearsIn', detail: 'Episode'}, + {label: 'friends', detail: 'Character'}, + {label: 'id', detail: 'String'}, + {label: 'name', detail: 'String'}, + {label: 'secretBackstory', detail: 'String'}, ]); }); it('provides correct field suggestions for fragments', () => { const result = testSuggestions( 'fragment test on Human { ', - new Point(0, 25), + new Position(0, 25), ); expect(result).to.deep.equal([ - {text: 'appearsIn', type: 'Episode'}, - {text: 'friends', type: 'Character'}, - {text: 'id', type: 'String'}, - {text: 'name', type: 'String'}, - {text: 'secretBackstory', type: 'String'}, + {label: 'appearsIn', detail: 'Episode'}, + {label: 'friends', detail: 'Character'}, + {label: 'id', detail: 'String'}, + {label: 'name', detail: 'String'}, + {label: 'secretBackstory', detail: 'String'}, ]); }); it('provides correct argument suggestions', () => { - const result = testSuggestions('{ human (', new Point(0, 9)); - expect(result).to.deep.equal([{text: 'id', type: 'String'}]); + const result = testSuggestions('{ human (', new Position(0, 9)); + expect(result).to.deep.equal([{label: 'id', detail: 'String'}]); }); it('provides correct argument suggestions when using aliases', () => { const result = testSuggestions( '{ aliasTest: human( ', - new Point(0, 20), + new Position(0, 20), ); - expect(result).to.deep.equal([{text: 'id', type: 'String'}]); + expect(result).to.deep.equal([{label: 'id', detail: 'String'}]); }); it('provides correct typeCondition suggestions', () => { - const suggestionsOnQuery = testSuggestions('{ ... on ', new Point(0, 9)); + const suggestionsOnQuery = testSuggestions('{ ... on ', new Position(0, 9)); expect( - suggestionsOnQuery.filter(({text}) => !text.startsWith('__')), - ).to.deep.equal([{text: 'Query'}]); + suggestionsOnQuery.filter(({label}) => !label.startsWith('__')), + ).to.deep.equal([{label: 'Query'}]); const suggestionsOnCompositeType = testSuggestions( - '{ hero(episode: JEDI) { ... on } }', new Point(0, 31), + '{ hero(episode: JEDI) { ... on } }', new Position(0, 31), ); expect(suggestionsOnCompositeType).to.deep.equal([ - {text: 'Character'}, - {text: 'Droid'}, - {text: 'Human'}, + {label: 'Character'}, + {label: 'Droid'}, + {label: 'Human'}, ]); expect(testSuggestions( 'fragment Foo on Character { ... on }', - new Point(0, 35), + new Position(0, 35), )).to.deep.equal([ - {text: 'Character'}, - {text: 'Droid'}, - {text: 'Human'}, + {label: 'Character'}, + {label: 'Droid'}, + {label: 'Human'}, ]); }); it('provides correct typeCondition suggestions on fragment', () => { const result = testSuggestions( 'fragment Foo on {}', - new Point(0, 16), + new Position(0, 16), ); - expect(result.filter(({text}) => !text.startsWith('__'))).to.deep.equal([ - {text: 'Character'}, - {text: 'Droid'}, - {text: 'Human'}, - {text: 'Query'}, - {text: 'TestType'}, + expect(result.filter(({label}) => !label.startsWith('__'))).to.deep.equal([ + {label: 'Character'}, + {label: 'Droid'}, + {label: 'Human'}, + {label: 'Query'}, + {label: 'TestType'}, ]); }); it('provides correct ENUM suggestions', () => { const result = testSuggestions( - '{ hero(episode: ', new Point(0, 16), + '{ hero(episode: ', new Position(0, 16), ); expect(result).to.deep.equal([ - {text: 'EMPIRE', type: 'Episode'}, - {text: 'JEDI', type: 'Episode'}, - {text: 'NEWHOPE', type: 'Episode'}, + {label: 'EMPIRE', detail: 'Episode'}, + {label: 'JEDI', detail: 'Episode'}, + {label: 'NEWHOPE', detail: 'Episode'}, ]); }); @@ -207,81 +207,81 @@ describe('getAutocompleteSuggestions', () => { // Test on concrete types expect(testSuggestions( `${fragmentDef} query { human(id: "1") { ...`, - new Point(0, 57), + new Position(0, 57), )).to.deep.equal([ - {text: 'Foo', type: 'Human'}, + {label: 'Foo', detail: 'Human'}, ]); expect(testSuggestions( `query { human(id: "1") { ... }} ${fragmentDef}`, - new Point(0, 28), + new Position(0, 28), )).to.deep.equal([ - {text: 'Foo', type: 'Human'}, + {label: 'Foo', detail: 'Human'}, ]); // Test on abstract type expect(testSuggestions( `${fragmentDef} query { hero(episode: JEDI) { ...`, - new Point(0, 62), + new Position(0, 62), )).to.deep.equal([ - {text: 'Foo', type: 'Human'}, + {label: 'Foo', detail: 'Human'}, ]); }); it('provides correct directive suggestions', () => { expect(testSuggestions( '{ test @', - new Point(0, 8), + new Position(0, 8), )).to.deep.equal([ - {text: 'include'}, - {text: 'skip'}, - {text: 'test'}, + {label: 'include'}, + {label: 'skip'}, + {label: 'test'}, ]); expect(testSuggestions( '{ aliasTest: test @ }', - new Point(0, 19), + new Position(0, 19), )).to.deep.equal([ - {text: 'include'}, - {text: 'skip'}, - {text: 'test'}, + {label: 'include'}, + {label: 'skip'}, + {label: 'test'}, ]); expect( - testSuggestions('query @', new Point(0, 7)), + testSuggestions('query @', new Position(0, 7)), ).to.deep.equal([]); }); it('provides correct testInput suggestions', () => { expect(testSuggestions( '{ inputTypeTest(args: {', - new Point(0, 23), + new Position(0, 23), )).to.deep.equal([ - {text: 'key', type: 'String'}, - {text: 'value', type: 'Int'}, + {label: 'key', detail: 'String'}, + {label: 'value', detail: 'Int'}, ]); }); it('provides correct field name suggestion inside inline fragment', () => { expect(testSuggestions( 'fragment Foo on Character { ... on Human { }}', - new Point(0, 42), + new Position(0, 42), )).to.deep.equal([ - {text: 'appearsIn', type: 'Episode'}, - {text: 'friends', type: 'Character'}, - {text: 'id', type: 'String'}, - {text: 'name', type: 'String'}, - {text: 'secretBackstory', type: 'String'}, + {label: 'appearsIn', detail: 'Episode'}, + {label: 'friends', detail: 'Character'}, + {label: 'id', detail: 'String'}, + {label: 'name', detail: 'String'}, + {label: 'secretBackstory', detail: 'String'}, ]); // Typeless inline fragment assumes the type automatically expect(testSuggestions( 'fragment Foo on Droid { ... { ', - new Point(0, 30), + new Position(0, 30), )).to.deep.equal([ - {text: 'appearsIn', type: 'Episode'}, - {text: 'friends', type: 'Character'}, - {text: 'id', type: 'String'}, - {text: 'name', type: 'String'}, - {text: 'primaryFunction', type: 'String'}, - {text: 'secretBackstory', type: 'String'}, + {label: 'appearsIn', detail: 'Episode'}, + {label: 'friends', detail: 'Character'}, + {label: 'id', detail: 'String'}, + {label: 'name', detail: 'String'}, + {label: 'primaryFunction', detail: 'String'}, + {label: 'secretBackstory', detail: 'String'}, ]); }); }); diff --git a/src/interfaces/__tests__/getDefinition-test.js b/src/interfaces/__tests__/getDefinition-test.js index 9807e10e..0bfbfd3e 100644 --- a/src/interfaces/__tests__/getDefinition-test.js +++ b/src/interfaces/__tests__/getDefinition-test.js @@ -32,8 +32,8 @@ describe('getDefinition', () => { [{file: 'someFile', content: fragment, definition: fragmentDefinition}], ); expect(result.definitions.length).to.equal(1); - expect(result.definitions[0].position.row).to.equal(1); - expect(result.definitions[0].position.column).to.equal(15); + expect(result.definitions[0].position.line).to.equal(1); + expect(result.definitions[0].position.character).to.equal(15); }); }); }); diff --git a/src/interfaces/__tests__/getDiagnostics-test.js b/src/interfaces/__tests__/getDiagnostics-test.js index 60831fe8..40d888fc 100644 --- a/src/interfaces/__tests__/getDiagnostics-test.js +++ b/src/interfaces/__tests__/getDiagnostics-test.js @@ -14,11 +14,8 @@ import {describe, it} from 'mocha'; import {getDiagnostics} from '../getDiagnostics'; describe('getDiagnostics', () => { - const fakePath = require.resolve('../getDiagnostics'); - it('catches syntax errors', () => { - const error = getDiagnostics(fakePath, 'qeury')[0]; - expect(error.text).to.contain('Unexpected Name "qeury"'); - expect(error.filePath).to.equal(fakePath); + const error = getDiagnostics('qeury')[0]; + expect(error.message).to.contain('Unexpected Name "qeury"'); }); }); diff --git a/src/interfaces/autocompleteUtils.js b/src/interfaces/autocompleteUtils.js index ba983c8c..b92f54a3 100644 --- a/src/interfaces/autocompleteUtils.js +++ b/src/interfaces/autocompleteUtils.js @@ -14,12 +14,11 @@ import type { GraphQLType, } from 'graphql/type/definition'; import type { - AutocompleteSuggestionType, + CompletionItem, ContextToken, State, TypeInfo, } from '../types/Types'; -import type {Point} from '../utils/Range'; import {isCompositeType} from 'graphql'; import { @@ -98,25 +97,24 @@ export function objectValues(object: Object): Array { // Create the expected hint response given a possible list and a token export function hintList( - cursor: Point, token: ContextToken, - list: Array, -): Array { + list: Array, +): Array { return filterAndSortList(list, normalizeText(token.string)); } // Given a list of hint entries and currently typed text, sort and filter to // provide a concise list. function filterAndSortList( - list: Array, + list: Array, text: string, -): Array { +): Array { if (!text) { return filterNonEmpty(list, entry => !entry.isDeprecated); } const byProximity = list.map(entry => ({ - proximity: getProximity(normalizeText(entry.text), text), + proximity: getProximity(normalizeText(entry.label), text), entry, })); @@ -128,7 +126,7 @@ function filterAndSortList( const sortedMatches = conciseMatches.sort((a, b) => ((a.entry.isDeprecated ? 1 : 0) - (b.entry.isDeprecated ? 1 : 0)) || (a.proximity - b.proximity) || - (a.entry.text.length - b.entry.text.length), + (a.entry.label.length - b.entry.label.length), ); return sortedMatches.map(pair => pair.entry); diff --git a/src/interfaces/getAutocompleteSuggestions.js b/src/interfaces/getAutocompleteSuggestions.js index 4c5ae793..c50fc138 100644 --- a/src/interfaces/getAutocompleteSuggestions.js +++ b/src/interfaces/getAutocompleteSuggestions.js @@ -14,12 +14,12 @@ import type { } from 'graphql/type/definition'; import type {ASTNode} from 'graphql/language'; import type { - AutocompleteSuggestionType, + CompletionItem, ContextToken, State, TypeInfo, } from '../types/Types'; -import type {Point} from '../utils/Range'; +import type {Position} from '../utils/Range'; import { isInputType, @@ -56,8 +56,8 @@ import onlineParser from '../parser/onlineParser'; export function getAutocompleteSuggestions( schema: GraphQLSchema, queryText: string, - cursor: Point, -): Array { + cursor: Position, +): Array { const token = getTokenAtPosition(queryText, cursor); const state = token.state.kind === 'Invalid' ? @@ -75,19 +75,18 @@ export function getAutocompleteSuggestions( // Definition kinds if (kind === 'Document') { - return hintList(cursor, token, [ - {text: 'query'}, - {text: 'mutation'}, - {text: 'subscription'}, - {text: 'fragment'}, - {text: '{'}, + return hintList(token, [ + {label: 'query'}, + {label: 'mutation'}, + {label: 'subscription'}, + {label: 'fragment'}, + {label: '{'}, ]); } // Field names if (kind === 'SelectionSet' || kind === 'Field' || kind === 'AliasedField') { return getSuggestionsForFieldNames( - cursor, token, typeInfo, schema, @@ -98,10 +97,10 @@ export function getAutocompleteSuggestions( if (kind === 'Arguments' || kind === 'Argument' && step === 0) { const argDefs = typeInfo.argDefs; if (argDefs) { - return hintList(cursor, token, argDefs.map(argDef => ({ - text: argDef.name, - type: argDef.type, - description: argDef.description, + return hintList(token, argDefs.map(argDef => ({ + label: argDef.name, + detail: argDef.type, + documentation: argDef.description, }))); } } @@ -110,10 +109,10 @@ export function getAutocompleteSuggestions( if (kind === 'ObjectValue' || kind === 'ObjectField' && step === 0) { if (typeInfo.objectFieldDefs) { const objectFields = objectValues(typeInfo.objectFieldDefs); - return hintList(cursor, token, objectFields.map(field => ({ - text: field.name, - type: field.type, - description: field.description, + return hintList(token, objectFields.map(field => ({ + label: field.name, + detail: field.type, + documentation: field.description, }))); } } @@ -125,7 +124,7 @@ export function getAutocompleteSuggestions( kind === 'ObjectField' && step === 2 || kind === 'Argument' && step === 2 ) { - return getSuggestionsForInputValues(cursor, token, typeInfo); + return getSuggestionsForInputValues(token, typeInfo); } // Fragment type conditions @@ -135,7 +134,6 @@ export function getAutocompleteSuggestions( state.prevState.kind === 'TypeCondition' ) { return getSuggestionsForFragmentTypeConditions( - cursor, token, typeInfo, schema, @@ -145,7 +143,6 @@ export function getAutocompleteSuggestions( // Fragment spread names if (kind === 'FragmentSpread' && step === 1) { return getSuggestionsForFragmentSpread( - cursor, token, typeInfo, schema, @@ -162,12 +159,12 @@ export function getAutocompleteSuggestions( state.prevState.kind === 'ListType' ) ) { - return getSuggestionsForVariableDefinition(cursor, token, schema); + return getSuggestionsForVariableDefinition(token, schema); } // Directive names if (kind === 'Directive') { - return getSuggestionsForDirective(cursor, token, state, schema); + return getSuggestionsForDirective(token, state, schema); } return []; @@ -175,11 +172,10 @@ export function getAutocompleteSuggestions( // Helper functions to get suggestions for each kinds function getSuggestionsForFieldNames( - cursor: Point, token: ContextToken, typeInfo: TypeInfo, schema: GraphQLSchema, -): Array { +): Array { if (typeInfo.parentType) { const parentType = typeInfo.parentType; const fields = parentType.getFields ? @@ -191,10 +187,10 @@ function getSuggestionsForFieldNames( if (parentType === schema.getQueryType()) { fields.push(SchemaMetaFieldDef, TypeMetaFieldDef); } - return hintList(cursor, token, fields.map(field => ({ - text: field.name, - type: field.type, - description: field.description, + return hintList(token, fields.map(field => ({ + label: field.name, + detail: field.type, + documentation: field.description, isDeprecated: field.isDeprecated, deprecationReason: field.deprecationReason, }))); @@ -203,25 +199,24 @@ function getSuggestionsForFieldNames( } function getSuggestionsForInputValues( - cursor: Point, token: ContextToken, typeInfo: TypeInfo, -): Array { +): Array { const namedInputType = getNamedType(typeInfo.inputType); if (namedInputType instanceof GraphQLEnumType) { const valueMap = namedInputType.getValues(); const values = objectValues(valueMap); - return hintList(cursor, token, values.map(value => ({ - text: value.name, - type: namedInputType, - description: value.description, + return hintList(token, values.map(value => ({ + label: value.name, + detail: namedInputType, + documentation: value.description, isDeprecated: value.isDeprecated, deprecationReason: value.deprecationReason, }))); } else if (namedInputType === GraphQLBoolean) { - return hintList(cursor, token, [ - {text: 'true', type: GraphQLBoolean, description: 'Not false.'}, - {text: 'false', type: GraphQLBoolean, description: 'Not true.'}, + return hintList(token, [ + {label: 'true', detail: GraphQLBoolean, documentation: 'Not false.'}, + {label: 'false', detail: GraphQLBoolean, documentation: 'Not true.'}, ]); } @@ -229,11 +224,10 @@ function getSuggestionsForInputValues( } function getSuggestionsForFragmentTypeConditions( - cursor: Point, token: ContextToken, typeInfo: TypeInfo, schema: GraphQLSchema, -): Array { +): Array { let possibleTypes; if (typeInfo.parentType) { if (isAbstractType(typeInfo.parentType)) { @@ -256,19 +250,18 @@ function getSuggestionsForFragmentTypeConditions( const typeMap = schema.getTypeMap(); possibleTypes = objectValues(typeMap).filter(isCompositeType); } - return hintList(cursor, token, possibleTypes.map(type => ({ - text: type.name, - description: type.description, + return hintList(token, possibleTypes.map(type => ({ + label: type.name, + documentation: type.description, }))); } function getSuggestionsForFragmentSpread( - cursor: Point, token: ContextToken, typeInfo: TypeInfo, schema: GraphQLSchema, queryText: string, -): Array { +): Array { const typeMap = schema.getTypeMap(); const defState = getDefinitionState(token.state); const fragments = getFragmentDefinitions(queryText); @@ -289,10 +282,10 @@ function getSuggestionsForFragmentSpread( ), ); - return hintList(cursor, token, relevantFrags.map(frag => ({ - text: frag.name.value, - type: typeMap[frag.typeCondition.name.value], - description: + return hintList(token, relevantFrags.map(frag => ({ + label: frag.name.value, + detail: typeMap[frag.typeCondition.name.value], + documentation: `fragment ${frag.name.value} on ${frag.typeCondition.name.value}`, }))); } @@ -322,44 +315,42 @@ function getFragmentDefinitions(queryText: string): Array { } function getSuggestionsForVariableDefinition( - cursor: Point, token: ContextToken, schema: GraphQLSchema, -): Array { +): Array { const inputTypeMap = schema.getTypeMap(); const inputTypes = objectValues(inputTypeMap).filter(isInputType); - return hintList(cursor, token, inputTypes.map(type => ({ - text: type.name, - description: type.description, + return hintList(token, inputTypes.map(type => ({ + label: type.name, + documentation: type.description, }))); } function getSuggestionsForDirective( - cursor: Point, token: ContextToken, state: State, schema: GraphQLSchema, -): Array { +): Array { if (state.prevState && state.prevState.kind) { const stateKind = state.prevState.kind; const directives = schema.getDirectives().filter( directive => canUseDirective(stateKind, directive), ); - return hintList(cursor, token, directives.map(directive => ({ - text: directive.name, - description: directive.description, + return hintList(token, directives.map(directive => ({ + label: directive.name, + documentation: directive.description, }))); } return []; } -function getTokenAtPosition(queryText: string, cursor: Point): ContextToken { +function getTokenAtPosition(queryText: string, cursor: Position): ContextToken { let styleAtCursor = null; let stateAtCursor = null; let stringAtCursor = null; const token = runOnlineParser(queryText, (stream, state, style, index) => { - if (index === cursor.row) { - if (stream.getCurrentPosition() > cursor.column) { + if (index === cursor.line) { + if (stream.getCurrentPosition() > cursor.character) { return 'BREAK'; } styleAtCursor = style; diff --git a/src/interfaces/getDefinition.js b/src/interfaces/getDefinition.js index e5c6edb5..a48adbb6 100644 --- a/src/interfaces/getDefinition.js +++ b/src/interfaces/getDefinition.js @@ -19,7 +19,7 @@ import type { Uri, } from '../types/Types'; -import {offsetToPoint, locToRange} from '../utils/Range'; +import {offsetToPosition, locToRange} from '../utils/Range'; export const LANGUAGE = 'GraphQL'; @@ -64,7 +64,7 @@ function getDefinitionForFragmentDefinition( ): Definition { return { path, - position: offsetToPoint(text, definition.name.loc.start), + position: offsetToPosition(text, definition.name.loc.start), range: locToRange(text, definition.loc), name: definition.name.value, language: LANGUAGE, diff --git a/src/interfaces/getDiagnostics.js b/src/interfaces/getDiagnostics.js index 2f5d0eb8..2977fb93 100644 --- a/src/interfaces/getDiagnostics.js +++ b/src/interfaces/getDiagnostics.js @@ -10,26 +10,28 @@ import type {GraphQLErrorLocation, GraphQLError} from 'graphql/error'; import type {ASTNode} from 'graphql/language'; -import type {DiagnosticType, CustomValidationRule, Uri} from '../types/Types'; +import type {Diagnostic, CustomValidationRule} from '../types/Types'; import invariant from 'assert'; import {parse} from 'graphql'; import CharacterStream from '../parser/CharacterStream'; import onlineParser from '../parser/onlineParser'; -import {Point, Range} from '../utils/Range'; +import {Position, Range} from '../utils/Range'; import {validateWithCustomRules} from '../utils/validateWithCustomRules'; +const SEVERITY = { + ERROR: 1, + WARNING: 2, + INFORMATION: 3, + HINT: 4, +}; + export function getDiagnostics( - filePath: string, queryText: string, schema: ?string = null, customRules?: Array, -): Array { - if (filePath === null) { - return []; - } - +): Array { let ast = null; try { ast = parse(queryText); @@ -40,17 +42,16 @@ export function getDiagnostics( ); return [{ - name: 'graphql: Syntax', - type: 'Error', - text: error.message, + severity: SEVERITY.ERROR, + message: error.message, + source: 'GraphQL: Syntax', range, - filePath, }]; } const errors: Array = schema ? validateWithCustomRules(schema, ast, customRules) : []; - return mapCat(errors, error => errorAnnotations(error, filePath)); + return mapCat(errors, error => errorAnnotations(error)); } // General utility for map-cating (aka flat-mapping). @@ -63,8 +64,7 @@ function mapCat( function errorAnnotations( error: GraphQLError, - filePath: Uri, -): Array { +): Array { if (!error.nodes) { return []; } @@ -78,14 +78,13 @@ function errorAnnotations( const loc = error.locations[0]; const end = loc.column + (highlightNode.loc.end - highlightNode.loc.start); return { - name: 'graphql: Validation', - text: error.message, - type: 'error', + source: 'GraphQL: Validation', + message: error.message, + severity: SEVERITY.ERROR, range: new Range( - new Point(loc.line - 1, loc.column), - new Point(loc.line - 1, end), + new Position(loc.line - 1, loc.column - 1), + new Position(loc.line - 1, end), ), - filePath, }; }); } @@ -121,5 +120,5 @@ function getRange(location: GraphQLErrorLocation, queryText: string) { const start = stream.getStartOfToken(); const end = stream.getCurrentPosition(); - return new Range(new Point(line, start), new Point(line, end)); + return new Range(new Position(line, start), new Position(line, end)); } diff --git a/src/interfaces/getOutline.js b/src/interfaces/getOutline.js index 96eb0915..b410270e 100644 --- a/src/interfaces/getOutline.js +++ b/src/interfaces/getOutline.js @@ -13,7 +13,7 @@ import type {Outline, TextToken, TokenKind} from '../types/Types'; import {parse, visit} from 'graphql'; import {INLINE_FRAGMENT} from 'graphql/language/kinds'; -import {offsetToPoint} from '../utils/Range'; +import {offsetToPosition} from '../utils/Range'; const OUTLINEABLE_KINDS = { Field: true, @@ -51,8 +51,8 @@ export function getOutline(queryText: string): ?Outline { function outlineTreeConverter(docText: string): OutlineTreeConverterType { const meta = node => ({ representativeName: node.name, - startPosition: offsetToPoint(docText, node.loc.start), - endPosition: offsetToPoint(docText, node.loc.end), + startPosition: offsetToPosition(docText, node.loc.start), + endPosition: offsetToPosition(docText, node.loc.end), children: node.selectionSet || [], }); return { diff --git a/src/server/MessageProcessor.js b/src/server/MessageProcessor.js new file mode 100644 index 00000000..24290d0f --- /dev/null +++ b/src/server/MessageProcessor.js @@ -0,0 +1,419 @@ +/** + * Copyright (c) Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Diagnostic, Uri} from '../types/Types'; + +import {getGraphQLCache} from './GraphQLCache'; + +import {getOutline} from '../interfaces/getOutline'; +import {GraphQLLanguageService} from '../interfaces/GraphQLLanguageService'; + +import {findGraphQLConfigDir} from '../config/GraphQLConfig'; + +import {Position} from '../utils/Range'; + +// Response message error codes +const ERROR_CODES = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + SERVER_ERROR_START: -32099, + SERVER_ERROR_END: -32000, + SERVER_NOT_INITIALIZED: -32002, + UNKNOWN_ERROR_CODE: -32001, +}; + +type RequestMessage = { + jsonrpc: string, + id: number | string, + method: string, + params?: any, +}; + +type ResponseMessage = { + jsonrpc: string, + id: number | string, + result?: any, + error?: ResponseError, +}; + +type ResponseError = { + code: number, + message: string, + data?: D, +}; + +type NotificationMessage = { + jsonrpc: string, + method: string, + params?: any, +}; + +type ServerCapabilities = { + capabilities: { + completionProvider: Object, + textDocumentSync: Object | number, + }, +}; + +const REQUEST_IDS_IN_PROGRESS = []; + +let graphQLCache; +let languageService; +const textDocumentCache: Map = new Map(); + +export async function processIPCNotificationMessage( + message: NotificationMessage, +): Promise { + throw new Error('Not yet implemented.'); +} + +export async function processIPCRequestMessage( + message: RequestMessage, + configDir: ?string, +): Promise { + const method = message.method; + let response; + let textDocument; + switch (method) { + case 'initialize': + if (!message.params || !message.params.rootPath) { + // `rootPath` is required + return; + } + const serverCapabilities = await initialize( + configDir ? configDir.trim() : message.params.rootPath, + ); + + if (serverCapabilities === null) { + response = convertToRpcMessage({ + id: '-1', + error: { + code: ERROR_CODES.SERVER_NOT_INITIALIZED, + message: '.graphqlrc not found', + }, + }); + } else { + response = convertToRpcMessage({ + id: message.id, + result: serverCapabilities, + }); + } + sendMessageIPC(response); + + break; + case 'textDocument/didOpen': + case 'textDocument/didSave': + if (!message.params || !message.params.textDocument) { + // `textDocument` is required. + return; + } + textDocument = message.params.textDocument; + const uri = textDocument.uri; + + let text = textDocument.text; + if (!text) { + if (textDocumentCache.has(textDocument.uri)) { + const cachedDocument = textDocumentCache.get(textDocument.uri); + if (cachedDocument) { + text = cachedDocument.content.text; + } + } + } + + const diagnostics = await provideDiagnosticsMessage(text, uri); + response = convertToRpcMessage({ + id: message.id, + method: 'textDocument/publishDiagnostics', + params: {uri, diagnostics}, + }); + sendMessageIPC(response); + break; + case 'textDocument/didChange': + // TODO: support onEdit diagnostics + // 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. autocompletions. + if ( + !message.params || + !message.params.textDocument || + !message.params.contentChanges + ) { + // `textDocument` and `contentChanges` are required. + return; + } + + textDocument = message.params.textDocument; + const contentChanges = message.params.contentChanges; + + if (textDocumentCache.has(textDocument.uri)) { + const cachedDocument = textDocumentCache.get(textDocument.uri); + if (cachedDocument && cachedDocument.version < textDocument.version) { + // Current server capabilities specify the full sync of the contents. + // Therefore always overwrite the entire content. + // Also, as `contentChanges` is an array and we just want the + // latest update to the text, grab the last entry from the array. + textDocumentCache.set(textDocument.uri, { + version: textDocument.version, + content: contentChanges[contentChanges.length - 1], + }); + } + } else { + textDocumentCache.set(textDocument.uri, { + version: textDocument.version, + content: contentChanges[contentChanges.length - 1], + }); + } + break; + case 'textDocument/didClose': + // 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 (!message.params || !message.params.textDocument) { + // `textDocument` is required. + return; + } + textDocument = message.params.textDocument; + + if (textDocumentCache.has(textDocument.uri)) { + textDocumentCache.delete(textDocument.uri); + } + break; + case 'textDocument/completion': + // `textDocument/comletion` 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. + // (response: Array) + if ( + !message.params || + !message.params.textDocument || + !message.params.position + ) { + // `textDocument` is required. + return; + } + textDocument = message.params.textDocument; + const position = message.params.position; + + if (textDocumentCache.has(textDocument.uri)) { + const cachedDocument = textDocumentCache.get(textDocument.uri); + if (cachedDocument) { + const query = cachedDocument.content.text; + const result = await languageService.getAutocompleteSuggestions( + query, + position, + textDocument.uri, + ); + + sendMessageIPC(convertToRpcMessage({ + id: message.id, + result, + })); + } + } + break; + case '$/cancelRequest': + if (!message.params || !message.params.id) { + // `id` is required. + return; + } + const requestIDToCancel = message.params.id; + const index = REQUEST_IDS_IN_PROGRESS.indexOf(requestIDToCancel); + if (index !== -1) { + REQUEST_IDS_IN_PROGRESS.splice(index, 1); + // A cancelled request still needs to send an empty response back + sendMessageIPC({id: requestIDToCancel}); + } + break; + case 'shutdown': + // prepare to shut down the server + break; + case 'exit': + process.exit(0); + break; + } +} + +export async function processStreamMessage( + message: string, + configDir: ?string, +): Promise { + if (message.length === 0) { + return; + } + if (!graphQLCache) { + const graphQLConfigDir = await findGraphQLConfigDir( + configDir ? configDir.trim() : process.cwd(), + ); + if (!graphQLConfigDir) { + process.stdout.write(JSON.stringify( + convertToRpcMessage({ + id: '-1', + error: { + code: ERROR_CODES.SERVER_NOT_INITIALIZED, + message: '.graphqlrc not found', + }, + }), + )); + return; + } + graphQLCache = await getGraphQLCache(graphQLConfigDir); + } + if (!languageService) { + languageService = new GraphQLLanguageService(graphQLCache); + } + + let json; + + try { + json = JSON.parse(message); + } catch (error) { + process.stdout.write(JSON.stringify( + convertToRpcMessage({ + id: '-1', + error: { + code: ERROR_CODES.PARSE_ERROR, + message: 'Request contains incorrect JSON format', + }, + }), + )); + return; + } + + const id = json.id; + const method = json.method; + + let result = null; + let responseMsg = null; + + const {query, filePath, position} = json.args; + + switch (method) { + case 'disconnect': + process.exit(0); + break; + case 'getDiagnostics': + result = await provideDiagnosticsMessage(query, filePath); + responseMsg = convertToRpcMessage({ + type: 'response', + id, + result, + }); + process.stdout.write(JSON.stringify(responseMsg) + '\n'); + break; + case 'getDefinition': + result = await languageService.getDefinition(query, position, filePath); + responseMsg = convertToRpcMessage({ + type: 'response', + id, + result, + }); + process.stdout.write(JSON.stringify(responseMsg) + '\n'); + break; + case 'getAutocompleteSuggestions': + result = await languageService.getAutocompleteSuggestions( + query, + position, + filePath, + ); + + const formatted = result.map( + res => ({ + text: res.label, + typeName: res.detail ? String(res.detail) : null, + description: res.documentation || null, + }), + ); + responseMsg = convertToRpcMessage({ + type: 'response', + id, + formatted, + }); + process.stdout.write(JSON.stringify(responseMsg) + '\n'); + break; + case 'getOutline': + result = getOutline(query); + responseMsg = convertToRpcMessage({ + type: 'response', + id, + result, + }); + process.stdout.write(JSON.stringify(responseMsg) + '\n'); + break; + default: + break; + } +} + +/** + * Helper functions to perform requested services from client/server. + */ + +async function initialize( + rootPath: Uri, +): Promise { + const serverCapabilities = { + capabilities: { + completionProvider: {resolveProvider: true}, + textDocumentSync: 1, + }, + }; + + const configDir = await findGraphQLConfigDir(rootPath); + if (!configDir) { + return null; + } + + graphQLCache = await getGraphQLCache(configDir); + languageService = new GraphQLLanguageService(graphQLCache); + + return serverCapabilities; +} + +async function provideDiagnosticsMessage( + query: string, + uri: Uri, +): Promise> { + let results = await languageService.getDiagnostics(query, uri); + if (results && results.length > 0) { + const queryLines = query.split('\n'); + const totalLines = queryLines.length; + const lastLineLength = queryLines[totalLines - 1].length; + const lastCharacterPosition = new Position(totalLines, lastLineLength); + results = results.filter(diagnostic => + diagnostic.range.end.lessThanOrEqualTo(lastCharacterPosition), + ); + } + + return results; +} + +function sendMessageIPC(message: any): void { + if (process.send !== undefined) { + process.send(message); + } +} + +/** + * Composes a language server protocol JSON message. + */ +function convertToRpcMessage(metaMessage: Object): ResponseMessage { + const message: ResponseMessage = { + jsonrpc: '2.0', + protocol: 'graphql_language_service', + ...metaMessage, + }; + + return message; +} diff --git a/src/server/startServer.js b/src/server/startServer.js index 403bdc22..e7691142 100755 --- a/src/server/startServer.js +++ b/src/server/startServer.js @@ -8,29 +8,34 @@ * @flow */ -import path from 'path'; - -import type {GraphQLCache} from './GraphQLCache'; -import {getGraphQLCache} from './GraphQLCache'; - -import {getOutline} from '../interfaces/getOutline'; -import {GraphQLLanguageService} from '../interfaces/GraphQLLanguageService'; - -import {Point} from '../utils/Range'; - -// RPC message types -const ERROR_RESPONSE_MESSAGE = 'error-response'; -const ERROR_MESSAGE = 'error'; -const RESPONSE_MESSAGE = 'response'; -const NEXT_MESSAGE = 'next'; -const COMPLETE_MESSAGE = 'complete'; - -export default async function startServer(rawConfigDir: string): Promise { - const configDir = path.resolve(rawConfigDir); +import { + processStreamMessage, + processIPCRequestMessage, + processIPCNotificationMessage, +} from './MessageProcessor'; + +export default async function startServer(configDir: ?string): Promise { + // IPC protocol support + // The language server protocol specifies that the client starts sending + // messages when the server starts. Start listening from this point. + process.on('message', message => { + // TODO: support the header part of the language server protocol + // Recognize the Content-Length header + if ( + typeof message === 'string' && + message.indexOf('Content-Length') === 0 + ) { + return; + } - const graphQLCache = await getGraphQLCache(configDir || process.cwd()); - const languageService = new GraphQLLanguageService(graphQLCache); + if (message.id !== undefined || message.id !== null) { + processIPCRequestMessage(message, configDir); + } else { + processIPCNotificationMessage(message); + } + }); + // Stream (stdio) protocol support // Depending on the size of the query, incomplete query strings // may be streamed in. The below code tries to detect the end of current // batch of streamed data, splits the batch into appropriate JSON string, @@ -47,139 +52,12 @@ export default async function startServer(rawConfigDir: string): Promise { data += chunk.toString(); // Check if the current buffer contains newline character. - const flagPosition = data.indexOf('\n'); + const flagPosition = data.indexOf('\r\n'); if (flagPosition !== -1) { // There may be more than one message in the buffer. - const messages = data.split('\n'); + const messages = data.split('\r\n'); data = messages.pop().trim(); - messages.forEach(message => processMessage( - message, - graphQLCache, - languageService, - )); + messages.forEach(message => processStreamMessage(message, configDir)); } }); } - -async function processMessage( - message: string, - graphQLCache: GraphQLCache, - languageService: GraphQLLanguageService, -): Promise { - if (message.length === 0) { - return; - } - - let json; - - try { - json = JSON.parse(message); - } catch (error) { - process.stdout.write(JSON.stringify( - convertToRpcMessage( - 'error', - '-1', - 'Request contains incorrect JSON format', - ), - )); - return; - } - - const {query, position, filePath} = json.args; - const id = json.id; - - try { - let result = null; - let responseMsg = null; - switch (json.method) { - case 'disconnect': - exitProcess(0); - break; - case 'getDiagnostics': - result = await languageService.getDiagnostics(query, filePath); - if (result && result.length > 0) { - const queryLines = query.split('\n'); - const totalRows = queryLines.length; - const lastLineLength = queryLines[totalRows - 1].length; - const lastCharacterPoint = new Point(totalRows, lastLineLength); - result = result.filter(diagnostic => - diagnostic.range.end.lessThanOrEqualTo(lastCharacterPoint), - ); - } - responseMsg = convertToRpcMessage( - 'response', - id, - result, - ); - process.stdout.write(JSON.stringify(responseMsg) + '\n'); - break; - case 'getDefinition': - result = await languageService.getDefinition(query, position, filePath); - responseMsg = convertToRpcMessage('response', id, result); - process.stdout.write(JSON.stringify(responseMsg) + '\n'); - break; - case 'getAutocompleteSuggestions': - result = await languageService.getAutocompleteSuggestions( - query, - position, - filePath, - ); - - const formatted = result.map( - res => ({ - text: res.text, - typeName: res.type ? String(res.type) : null, - description: res.description || null, - }), - ); - responseMsg = convertToRpcMessage('response', id, formatted); - process.stdout.write(JSON.stringify(responseMsg) + '\n'); - break; - case 'getOutline': - result = getOutline(query); - responseMsg = convertToRpcMessage('response', id, result); - process.stdout.write(JSON.stringify(responseMsg) + '\n'); - break; - default: - break; - } - } catch (error) { - process.stdout.write( - JSON.stringify(convertToRpcMessage('error', id, error.message)), - ); - } -} - -function exitProcess(exitCode) { - process.exit(exitCode); -} - -function convertToRpcMessage( - type: string, - id: string, - response: any, -) { - let responseObj; - switch (type) { - case RESPONSE_MESSAGE: - responseObj = {result: response}; - break; - case ERROR_MESSAGE: - case ERROR_RESPONSE_MESSAGE: - responseObj = {error: response}; - break; - case NEXT_MESSAGE: - responseObj = {value: response}; - break; - case COMPLETE_MESSAGE: - // Intentionally blank - responseObj = {}; - break; - } - return { - protocol: 'graphql_language_service', - type, - id, - ...responseObj, - }; -} diff --git a/src/types/Types.js b/src/types/Types.js index 3ed168d9..1adf001d 100644 --- a/src/types/Types.js +++ b/src/types/Types.js @@ -17,7 +17,7 @@ import type { GraphQLType, } from 'graphql/type/definition'; import type CharacterStream from '../parser/CharacterStream'; -import type {Point, Range} from '../utils/Range'; +import type {Position, Range} from '../utils/Range'; // online-parser related export type ParseRule = @@ -96,28 +96,30 @@ export type FragmentInfo = { export type CustomValidationRule = (context: ValidationContext) => Object; -export type DiagnosticType = { - name: string, - type: string, - text: string, +export type Diagnostic = { range: Range, - filePath: Uri, + severity?: number, + code?: number | string, + source?: string, + message: string, }; -export type AutocompleteSuggestionType = { - text: string, - type?: GraphQLType, - description?: ?string, +export type CompletionItem = { + label: string, + kind?: number, + detail?: string, + documentation?: string, + // GraphQL Deprecation information isDeprecated?: ?string, deprecationReason?: ?string, }; // Below are basically a copy-paste from Nuclide rpc types for definitions. -// + // Definitions/hyperlink export type Definition = { path: Uri, - position: Point, + position: Position, range?: Range, id?: string, name?: string, @@ -151,8 +153,8 @@ export type OutlineTree = { tokenizedText?: TokenizedText, representativeName?: string, - startPosition: Point, - endPosition?: Point, + startPosition: Position, + endPosition?: Position, children: Array, }; export type Outline = { diff --git a/src/utils/Range.js b/src/utils/Range.js index 2f6b7c0d..146decd4 100644 --- a/src/utils/Range.js +++ b/src/utils/Range.js @@ -11,34 +11,35 @@ import type {Location} from 'graphql/language'; export class Range { - start: Point; - end: Point; - constructor(start: Point, end: Point): void { + start: Position; + end: Position; + constructor(start: Position, end: Position): void { this.start = start; this.end = end; } - containsPoint(point: Point): boolean { - const withinRow = - this.start.row <= point.row && this.end.row >= point.row; - const withinColumn = - this.start.column <= point.column && this.end.column >= point.column; - return withinRow && withinColumn; + containsPosition(position: Position): boolean { + const withinLine = + this.start.line <= position.line && this.end.line >= position.line; + const withinCharacter = + this.start.character <= position.character && + this.end.character >= position.character; + return withinLine && withinCharacter; } } -export class Point { - row: number; - column: number; - constructor(row: number, column: number): void { - this.row = row; - this.column = column; +export class Position { + line: number; + character: number; + constructor(line: number, character: number): void { + this.line = line; + this.character = character; } - lessThanOrEqualTo(point: Point): boolean { + lessThanOrEqualTo(position: Position): boolean { if ( - this.row < point.row || - (this.row === point.row && this.column <= point.column) + this.line < position.line || + (this.line === position.line && this.character <= position.character) ) { return true; } @@ -47,16 +48,16 @@ export class Point { } } -export function offsetToPoint(text: string, loc: number): Point { +export function offsetToPosition(text: string, loc: number): Position { const EOL = '\n'; const buf = text.slice(0, loc); - const rows = buf.split(EOL).length - 1; + const lines = buf.split(EOL).length - 1; const lastLineIndex = buf.lastIndexOf(EOL); - return new Point(rows, loc - lastLineIndex - 1); + return new Position(lines, loc - lastLineIndex - 1); } export function locToRange(text: string, loc: Location): Range { - const start = offsetToPoint(text, loc.start); - const end = offsetToPoint(text, loc.end); + const start = offsetToPosition(text, loc.start); + const end = offsetToPosition(text, loc.end); return new Range(start, end); } diff --git a/src/utils/__tests__/getASTNodeAtPoint-test.js b/src/utils/__tests__/getASTNodeAtPosition-test.js similarity index 67% rename from src/utils/__tests__/getASTNodeAtPoint-test.js rename to src/utils/__tests__/getASTNodeAtPosition-test.js index 94ac95d9..f67191dc 100644 --- a/src/utils/__tests__/getASTNodeAtPoint-test.js +++ b/src/utils/__tests__/getASTNodeAtPosition-test.js @@ -12,8 +12,8 @@ import {expect} from 'chai'; import {parse} from 'graphql'; import {describe, it} from 'mocha'; -import {Point} from '../Range'; -import {getASTNodeAtPoint, pointToOffset} from '../getASTNodeAtPoint'; +import {Position} from '../Range'; +import {getASTNodeAtPosition, pointToOffset} from '../getASTNodeAtPosition'; const doc = ` query A { @@ -26,10 +26,10 @@ fragment B on B { const ast = parse(doc); -describe('getASTNodeAtPoint', () => { +describe('getASTNodeAtPosition', () => { it('gets the node at the beginning', () => { - const point = new Point(2, 0); - const node = getASTNodeAtPoint(doc, ast, point); + const point = new Position(2, 0); + const node = getASTNodeAtPosition(doc, ast, point); expect(node).to.not.be.undefined; if (node != null) { expect(node.name.value).to.equal('field'); @@ -37,8 +37,8 @@ describe('getASTNodeAtPoint', () => { }); it('does not find the node before the beginning', () => { - const point = new Point(0, 0); - const node = getASTNodeAtPoint(doc, ast, point); + const point = new Position(0, 0); + const node = getASTNodeAtPosition(doc, ast, point); expect(node).to.not.be.undefined; if (node != null) { expect(node.kind).to.equal('Document'); @@ -46,8 +46,8 @@ describe('getASTNodeAtPoint', () => { }); it('gets the node at the end', () => { - const point = new Point(2, 5); - const node = getASTNodeAtPoint(doc, ast, point); + const point = new Position(2, 5); + const node = getASTNodeAtPosition(doc, ast, point); expect(node).to.not.be.undefined; if (node != null) { expect(node.name.value).to.equal('field'); @@ -55,8 +55,8 @@ describe('getASTNodeAtPoint', () => { }); it('does not find the node after the end', () => { - const point = new Point(4, 0); - const node = getASTNodeAtPoint(doc, ast, point); + const point = new Position(4, 0); + const node = getASTNodeAtPosition(doc, ast, point); expect(node).to.not.be.undefined; if (node != null) { expect(node.kind).to.equal('Document'); @@ -67,13 +67,13 @@ describe('getASTNodeAtPoint', () => { describe('pointToOffset', () => { it('works for single lines', () => { const text = 'lorem'; - expect(pointToOffset(text, new Point(0, 2))).to.equal(2); + expect(pointToOffset(text, new Position(0, 2))).to.equal(2); }); it('takes EOL into account', () => { const text = 'lorem\n'; expect( - pointToOffset(text, new Point(1, 0)), + pointToOffset(text, new Position(1, 0)), ).to.equal( text.length, ); diff --git a/src/utils/getASTNodeAtPoint.js b/src/utils/getASTNodeAtPosition.js similarity index 67% rename from src/utils/getASTNodeAtPoint.js rename to src/utils/getASTNodeAtPosition.js index 15aa779f..740be24b 100644 --- a/src/utils/getASTNodeAtPoint.js +++ b/src/utils/getASTNodeAtPosition.js @@ -10,23 +10,23 @@ import type {ASTNode} from 'graphql/language'; -import {Point} from './Range'; +import {Position} from './Range'; import {visit} from 'graphql'; -export function getASTNodeAtPoint( +export function getASTNodeAtPosition( query: string, ast: ASTNode, - point: Point, + point: Position, ): ?ASTNode { const offset = pointToOffset(query, point); - let nodeContainingPoint: ?ASTNode; + let nodeContainingPosition: ?ASTNode; visit(ast, { enter(node) { if ( node.kind !== 'Name' && // We're usually interested in their parents node.loc.start <= offset && offset <= node.loc.end ) { - nodeContainingPoint = node; + nodeContainingPosition = node; } else { return false; } @@ -37,12 +37,12 @@ export function getASTNodeAtPoint( } }, }); - return nodeContainingPoint; + return nodeContainingPosition; } -export function pointToOffset(text: string, point: Point): number { - const linesUntilPoint = text.split('\n').slice(0, point.row); - return point.column + linesUntilPoint.map(line => +export function pointToOffset(text: string, point: Position): number { + const linesUntilPosition = text.split('\n').slice(0, point.line); + return point.character + linesUntilPosition.map(line => line.length + 1, // count EOL ).reduce((a, b) => a + b, 0); }