diff --git a/.changeset/rotten-seahorses-fry.md b/.changeset/rotten-seahorses-fry.md new file mode 100644 index 00000000000..7347f6d0a92 --- /dev/null +++ b/.changeset/rotten-seahorses-fry.md @@ -0,0 +1,46 @@ +--- +'graphql-language-service-server': minor +'vscode-graphql': minor +'graphql-language-service-server-cli': minor +--- + +Fix many schema and fragment lifecycle issues, not all of them, but many related to cacheing. +Note: this makes `cacheSchemaForLookup` enabled by default again for schema first contexts. + +This fixes multiple cacheing bugs, upon addomg some in-depth integration test coverage for the LSP server. +It also solves several bugs regarding loading config types, and properly restarts the server and invalidates schema when there are config changes. + +### Bugfix Summary + +- configurable polling updates for network and other code first schema configuration, set to a 30s interval by default. powered by `schemaCacheTTL` which can be configured in the IDE settings (vscode, nvim) or in the graphql config file. (1) +- jump to definition in embedded files offset bug, for both fragments and code files with SDL strings +- cache invalidation for fragments (fragment lookup/autcoomplete data is more accurate, but incomplete/invalid fragments still do not autocomplete or validate, and remember fragment options always filter/validate by the `on` type!) +- schema cache invalidation for schema files - schema updates as you change the SDL files, and the generated file for code first by the `schemaCacheTTL` setting +- schema definition lookups & autocomplete crossing over into the wrong project + +**Notes** + +1. If possible, configuring for your locally running framework or a schema registry client to handle schema updates and output to a `schema.graphql` or `introspection.json` will always provide a better experience. many graphql frameworks have this built in! Otherwise, we must use this new lazy polling approach if you provide a url schema (this includes both introspection URLs and remote file URLs, and the combination of these). + +### Known Bugs Fixed + +- #3318 +- #2357 +- #3469 +- #2422 +- #2820 +- many more! + +### Test Improvements + +- new, high level integration spec suite for the LSP with a matching test utility +- more unit test coverage +- **total increased test coverage of about 25% in the LSP server codebase.** +- many "happy paths" covered for both schema and code first contexts +- many bugs revealed (and their source) + +### What's next? + +Another stage of the rewrite is already almost ready. This will fix even more bugs and improve memory usage, eliminate redundant parsing and ensure that graphql config's loaders do _all_ of the parsing and heavy lifting, thus honoring all the configs as well. It also significantly reduces the code complexity. + +There is also a plan to match Relay LSP's lookup config for either IDE (vscode, nvm, etc) settings as they provide, or by loading modules into your `graphql-config`! diff --git a/.changeset/silly-yaks-bathe.md b/.changeset/silly-yaks-bathe.md new file mode 100644 index 00000000000..d88566186a6 --- /dev/null +++ b/.changeset/silly-yaks-bathe.md @@ -0,0 +1,15 @@ +--- +'graphql-language-service': patch +'graphql-language-service-server': patch +'graphql-language-service-server-cli': patch +'codemirror-graphql': patch +'cm6-graphql': patch +'monaco-graphql': patch +'vscode-graphql': patch +--- + +Fixes several issues with Type System (SDL) completion across the ecosystem: + +- restores completion for object and input type fields when the document context is not detectable or parseable +- correct top-level completions for either of the unknown, type system or executable definitions. this leads to mixed top level completions when the document is unparseable, but now you are not seemingly restricted to only executable top level definitions +- `.graphqls` ad-hoc standard functionality remains, but is not required, as it is not part of the official spec, and the spec also allows mixed mode documents in theory, and this concept is required when the type is unknown diff --git a/.changeset/wet-toes-mate.md b/.changeset/wet-toes-mate.md new file mode 100644 index 00000000000..fa26a5470d6 --- /dev/null +++ b/.changeset/wet-toes-mate.md @@ -0,0 +1,72 @@ +--- +'graphql-language-service-server': minor +'vscode-graphql': patch +--- + +Introduce `locateCommand` based on Relay LSP `pathToLocateCommand`: + +Now with `.extensions.languageService.locateCommand`, you can specify either the [existing signature](https://marketplace.visualstudio.com/items?itemName=meta.relay#relay.pathtolocatecommand-default-null) for relay, with the same callback parameters and return signature. + +Relay LSP currently supports `Type` and `Type.field` for the 2nd argument. Ours also returns `Type.field(argument)` as a point of reference. It works with object types, input types, fragments, executable definitions and their fields, and should work for directive definitions as well. + +In the case of unnamed types such as fragment spreads, they return the name of the implemented type currently, but I'm curious what users prefer here. I assumed that some people may want to not be limited to only using this for SDL type definition lookups. Also look soon to see `locateCommand` support added for symbols, outline, and coming references and implementations. + +The module at the path you specify in relay LSP for `pathToLocateCommand` should work as such + +```ts +// import it +import { locateCommand } from './graphql/tooling/lsp/locate.js'; +export default { + languageService: { + locateCommand, + }, + + projects: { + a: { + schema: 'https://localhost:8000/graphql', + documents: './a/**/*.{ts,tsx,jsx,js,graphql}', + }, + b: { + schema: './schema/ascode.ts', + documents: './b/**/*.{ts,tsx,jsx,js,graphql}', + }, + }, +}; +``` + +```ts +// or define it inline + +import { type LocateCommand } from 'graphql-language-service-server'; + +// relay LSP style +const languageCommand = (projectName: string, typePath: string) => { + const { path, startLine, endLine } = ourLookupUtility(projectName, typePath); + return `${path}:${startLine}:${endLine}`; +}; + +// an example with our alternative return signature +const languageCommand: LocateCommand = (projectName, typePath, info) => { + // pass more info, such as GraphQLType with the ast node. info.project is also available if you need it + const { path, range } = ourLookupUtility( + projectName, + typePath, + info.type.node, + ); + return { uri: path, range }; // range.start.line/range.end.line +}; + +export default { + languageService: { + locateCommand, + }, + schema: 'https://localhost:8000/graphql', + documents: './**/*.{ts,tsx,jsx,js,graphql}', +}; +``` + +Passing a string as a module path to resolve is coming in a follow-up release. Then it can be used with `.yml`, `.toml`, `.json`, `package.json#graphql`, etc + +For now this was a quick baseline for a feature asked for in multiple channels! + +Let us know how this works, and about any other interoperability improvements between our graphql LSP and other language servers (relay, intellij, etc) used by you and colleauges in your engineering organisations. We are trying our best to keep up with the awesome innovations they have 👀! diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b6c35bd1a43..4748bd5f52a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -13,7 +13,6 @@ jobs: uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 16 cache: yarn - name: Cache node modules id: cache-modules @@ -37,8 +36,7 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 - with: - node-version: 16 + - id: cache-modules uses: actions/cache@v3 with: @@ -59,8 +57,6 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 - with: - node-version: 16 - id: cache-modules uses: actions/cache@v3 with: @@ -76,8 +72,6 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 - with: - node-version: 16 - id: cache-modules uses: actions/cache@v3 with: @@ -87,14 +81,12 @@ jobs: - run: yarn pretty-check jest: - name: Jest Unit Tests + name: Jest Unit & Integration Tests runs-on: ubuntu-latest needs: [install] steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 - with: - node-version: 16 - id: cache-modules uses: actions/cache@v3 with: @@ -117,8 +109,7 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 - with: - node-version: 16 + - id: cache-modules uses: actions/cache@v3 with: @@ -140,8 +131,7 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 - with: - node-version: 16 + - id: cache-modules uses: actions/cache@v3 with: @@ -205,8 +195,6 @@ jobs: with: fetch-depth: 0 - uses: actions/setup-node@v3 - with: - node-version: 16 - id: cache-modules uses: actions/cache@v3 with: @@ -264,8 +252,6 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 - with: - node-version: 16 - id: cache-modules uses: actions/cache@v3 with: diff --git a/.nvmrc b/.nvmrc index 19c7bdba7b1..209e3ef4b62 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16 \ No newline at end of file +20 diff --git a/cspell.json b/cspell.json index 24ff11ab942..9b714e08196 100644 --- a/cspell.json +++ b/cspell.json @@ -9,7 +9,7 @@ "dictionaryDefinitions": [ { "name": "custom-words", - "path": "./custom-words.txt", + "path": "./resources/custom-words.txt", "addWords": true } ], diff --git a/examples/monaco-graphql-react-vite/vite.config.ts b/examples/monaco-graphql-react-vite/vite.config.ts index 889998bbc5b..0d600ec16f2 100644 --- a/examples/monaco-graphql-react-vite/vite.config.ts +++ b/examples/monaco-graphql-react-vite/vite.config.ts @@ -19,5 +19,24 @@ export default defineConfig({ }, ], }), + watchPackages(['monaco-graphql', 'graphql-language-service']), ], }); + +function watchPackages(packageNames: string[]) { + let isWatching = false; + + return { + name: 'vite-plugin-watch-packages', + + buildStart() { + if (!isWatching) { + for (const packageName of packageNames) { + this.addWatchFile(require.resolve(packageName)); + } + + isWatching = true; + } + }, + }; +} diff --git a/jest.config.base.js b/jest.config.base.js index 15e87eda8f8..62e5eacd442 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -43,7 +43,7 @@ module.exports = (dir, env = 'jsdom') => { 'node_modules', '__tests__', 'resources', - 'test', + 'examples', '.d.ts', 'types.ts', diff --git a/package.json b/package.json index 21399374029..ddf616af046 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,12 @@ "lint-staged": { "*.{js,ts,jsx,tsx}": [ "eslint --cache --fix", - "prettier --write --ignore-path .eslintignore", + "prettier --write --ignore-path .eslintignore --ignore-path resources/prettierignore", "jest --passWithNoTests", "yarn lint-cspell" ], "*.{md,html,json,css}": [ - "prettier --write --ignore-path .eslintignore", + "prettier --write --ignore-path .eslintignore --ignore-path resources/prettierignore", "yarn lint-cspell" ] }, @@ -43,7 +43,7 @@ "build:watch": "yarn tsc --watch", "build-demo": "wsrun -m build-demo", "watch": "yarn build:watch", - "watch-vscode": "yarn workspace vscode-graphql compile", + "watch-vscode": "yarn tsc && yarn workspace vscode-graphql compile", "watch-vscode-exec": "yarn workspace vscode-graphql-execution compile", "check": "yarn tsc --noEmit", "cypress-open": "yarn workspace graphiql cypress-open", @@ -62,7 +62,7 @@ "prepublishOnly": "./scripts/prepublish.sh", "postbuild": "wsrun --exclude-missing postbuild", "pretty": "yarn pretty-check --write", - "pretty-check": "prettier --cache --check --ignore-path .gitignore --ignore-path .eslintignore .", + "pretty-check": "prettier --cache --check --ignore-path .gitignore --ignore-path resources/prettierignore --ignore-path .eslintignore .", "ci:version": "yarn changeset version && yarn build && yarn format", "release": "yarn build && yarn build-bundles && (wsrun release --exclude-missing --serial --recursive --changedSince main -- || true) && yarn changeset publish", "release:canary": "(node scripts/canary-release.js && yarn build-bundles && yarn changeset publish --tag canary) || echo Skipping Canary...", diff --git a/packages/cm6-graphql/src/completions.ts b/packages/cm6-graphql/src/completions.ts index 27131cf6975..383c69eb7c3 100644 --- a/packages/cm6-graphql/src/completions.ts +++ b/packages/cm6-graphql/src/completions.ts @@ -26,7 +26,14 @@ export const completion = graphqlLanguage.data.of({ } const val = ctx.state.doc.toString(); const pos = offsetToPos(ctx.state.doc, ctx.pos); - const results = getAutocompleteSuggestions(schema, val, pos); + const results = getAutocompleteSuggestions( + schema, + val, + pos, + undefined, + undefined, + opts?.autocompleteOptions, + ); if (results.length === 0) { return null; diff --git a/packages/cm6-graphql/src/interfaces.ts b/packages/cm6-graphql/src/interfaces.ts index f4128928dba..10ab2f2a153 100644 --- a/packages/cm6-graphql/src/interfaces.ts +++ b/packages/cm6-graphql/src/interfaces.ts @@ -1,7 +1,11 @@ import { Completion, CompletionContext } from '@codemirror/autocomplete'; import { EditorView } from '@codemirror/view'; import { GraphQLSchema } from 'graphql'; -import { ContextToken, CompletionItem } from 'graphql-language-service'; +import { + ContextToken, + CompletionItem, + AutocompleteSuggestionOptions, +} from 'graphql-language-service'; import { Position } from './helpers'; export interface GqlExtensionsOptions { showErrorOnInvalidSchema?: boolean; @@ -18,4 +22,5 @@ export interface GqlExtensionsOptions { ctx: CompletionContext, item: Completion, ) => Node | Promise | null; + autocompleteOptions?: AutocompleteSuggestionOptions; } diff --git a/packages/codemirror-graphql/src/__tests__/hint-test.ts b/packages/codemirror-graphql/src/__tests__/hint-test.ts index c71d70a0798..4324ce5eef7 100644 --- a/packages/codemirror-graphql/src/__tests__/hint-test.ts +++ b/packages/codemirror-graphql/src/__tests__/hint-test.ts @@ -32,6 +32,7 @@ import { UnionFirst, UnionSecond, } from './testSchema'; +import { GraphQLDocumentMode } from 'graphql-language-service'; function createEditorWithHint() { return CodeMirror(document.createElement('div'), { @@ -45,7 +46,11 @@ function createEditorWithHint() { }); } -function getHintSuggestions(queryString: string, cursor: CodeMirror.Position) { +function getHintSuggestions( + queryString: string, + cursor: CodeMirror.Position, + opts?: GraphQLHintOptions, +) { const editor = createEditorWithHint(); return new Promise(resolve => { @@ -54,7 +59,7 @@ function getHintSuggestions(queryString: string, cursor: CodeMirror.Position) { cm: CodeMirror.Editor, options: GraphQLHintOptions, ) => { - const result = graphqlHint(cm, options); + const result = graphqlHint(cm, { ...opts, ...options }); resolve(result); CodeMirror.hint.graphql = graphqlHint; return result; @@ -82,14 +87,38 @@ describe('graphql-hint', () => { expect(editor.getHelpers(editor.getCursor(), 'hint')).not.toHaveLength(0); }); - it('provides correct initial keywords', async () => { + it('provides correct initial keywords for executable definitions', async () => { + const suggestions = await getHintSuggestions( + '', + { line: 0, ch: 0 }, + { autocompleteOptions: { mode: GraphQLDocumentMode.EXECUTABLE } }, + ); + const list = [ + { text: 'query' }, + { text: 'mutation' }, + { text: 'subscription' }, + { text: 'fragment' }, + { text: '{' }, + ]; + const expectedSuggestions = getExpectedSuggestions(list); + expect(suggestions?.list).toEqual(expectedSuggestions); + }); + + it('provides correct initial keywords for unknown definitions', async () => { const suggestions = await getHintSuggestions('', { line: 0, ch: 0 }); const list = [ + { text: 'extend' }, { text: 'query' }, { text: 'mutation' }, { text: 'subscription' }, { text: 'fragment' }, { text: '{' }, + { text: 'type' }, + { text: 'interface' }, + { text: 'union' }, + { text: 'input' }, + { text: 'scalar' }, + { text: 'schema' }, ]; const expectedSuggestions = getExpectedSuggestions(list); expect(suggestions?.list).toEqual(expectedSuggestions); @@ -946,8 +975,8 @@ describe('graphql-hint', () => { description: 'The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID.', }, - { text: 'TestEnum' }, - { text: 'TestInput' }, + { text: 'TestEnum', description: '' }, + { text: 'TestInput', description: '' }, { text: '__TypeKind', description: @@ -993,8 +1022,8 @@ describe('graphql-hint', () => { description: 'The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID.', }, - { text: 'TestEnum' }, - { text: 'TestInput' }, + { text: 'TestEnum', description: '' }, + { text: 'TestInput', description: '' }, { text: '__TypeKind', description: diff --git a/packages/codemirror-graphql/src/hint.ts b/packages/codemirror-graphql/src/hint.ts index a3a0ae0e308..e3606c94cf1 100644 --- a/packages/codemirror-graphql/src/hint.ts +++ b/packages/codemirror-graphql/src/hint.ts @@ -13,12 +13,16 @@ import CodeMirror, { Hints, Hint } from 'codemirror'; import 'codemirror/addon/hint/show-hint'; import { FragmentDefinitionNode, GraphQLSchema, GraphQLType } from 'graphql'; -import type { Maybe } from 'graphql-language-service'; +import type { + AutocompleteSuggestionOptions, + Maybe, +} from 'graphql-language-service'; import { getAutocompleteSuggestions, Position } from 'graphql-language-service'; export interface GraphQLHintOptions { schema?: GraphQLSchema; externalFragments?: string | FragmentDefinitionNode[]; + autocompleteOptions?: AutocompleteSuggestionOptions; } interface IHint extends Hint { @@ -70,7 +74,7 @@ CodeMirror.registerHelper( editor: CodeMirror.Editor, options: GraphQLHintOptions, ): IHints | undefined => { - const { schema, externalFragments } = options; + const { schema, externalFragments, autocompleteOptions } = options; if (!schema) { return; } @@ -91,11 +95,13 @@ CodeMirror.registerHelper( position, token, externalFragments, + autocompleteOptions, ); const results = { list: rawResults.map(item => ({ - text: item.label, + // important! for when the label is different from the insert text + text: item?.rawInsert ?? item.label, type: item.type, description: item.documentation, isDeprecated: item.isDeprecated, diff --git a/packages/codemirror-graphql/src/info.ts b/packages/codemirror-graphql/src/info.ts index 4b57bf1fecd..4f6352969a8 100644 --- a/packages/codemirror-graphql/src/info.ts +++ b/packages/codemirror-graphql/src/info.ts @@ -63,13 +63,13 @@ CodeMirror.registerHelper( } const { kind, step } = token.state; const typeInfo = getTypeInfo(options.schema, token.state); - // Given a Schema and a Token, produce the contents of an info tooltip. // To do this, create a div element that we will render "into" and then pass // it to various rendering functions. if ( (kind === 'Field' && step === 0 && typeInfo.fieldDef) || - (kind === 'AliasedField' && step === 2 && typeInfo.fieldDef) + (kind === 'AliasedField' && step === 2 && typeInfo.fieldDef) || + (kind === 'ObjectField' && step === 0 && typeInfo.fieldDef) ) { const header = document.createElement('div'); header.className = 'CodeMirror-info-header'; @@ -79,6 +79,7 @@ CodeMirror.registerHelper( renderDescription(into, options, typeInfo.fieldDef as any); return into; } + if (kind === 'Directive' && step === 1 && typeInfo.directiveDef) { const header = document.createElement('div'); header.className = 'CodeMirror-info-header'; diff --git a/packages/codemirror-graphql/src/utils/getTypeInfo.ts b/packages/codemirror-graphql/src/utils/getTypeInfo.ts index d61d172e126..6e14588d4d9 100644 --- a/packages/codemirror-graphql/src/utils/getTypeInfo.ts +++ b/packages/codemirror-graphql/src/utils/getTypeInfo.ts @@ -147,6 +147,8 @@ export default function getTypeInfo(schema: GraphQLSchema, tokenState: State) { ? info.objectFieldDefs[state.name] : null; info.inputType = objectField?.type; + // @ts-expect-error + info.fieldDef = objectField; break; case 'NamedType': info.type = state.name ? schema.getType(state.name) : null; diff --git a/packages/graphiql-react/src/editor/query-editor.ts b/packages/graphiql-react/src/editor/query-editor.ts index 33147427dd2..48c8aec4f97 100644 --- a/packages/graphiql-react/src/editor/query-editor.ts +++ b/packages/graphiql-react/src/editor/query-editor.ts @@ -6,7 +6,10 @@ import type { GraphQLSchema, ValidationRule, } from 'graphql'; -import { getOperationFacts } from 'graphql-language-service'; +import { + getOperationFacts, + GraphQLDocumentMode, +} from 'graphql-language-service'; import { MutableRefObject, useCallback, @@ -186,6 +189,10 @@ export function useQueryEditor( completeSingle: false, container, externalFragments: undefined, + autocompleteOptions: { + // for the query editor, restrict to executable type definitions + mode: GraphQLDocumentMode.EXECUTABLE, + }, }, info: { schema: undefined, diff --git a/packages/graphiql/test/afterDevServer.js b/packages/graphiql/test/afterDevServer.js index d47ef13f274..cf055ee66be 100644 --- a/packages/graphiql/test/afterDevServer.js +++ b/packages/graphiql/test/afterDevServer.js @@ -10,4 +10,5 @@ module.exports = function afterDevServer(_app, _server, _compiler) { }); // eslint-disable-next-line react-hooks/rules-of-hooks useServer({ schema }, wsServer); + return wsServer; }; diff --git a/packages/graphiql/test/e2e-server.js b/packages/graphiql/test/e2e-server.js index a714e5be590..d3847bcad4e 100644 --- a/packages/graphiql/test/e2e-server.js +++ b/packages/graphiql/test/e2e-server.js @@ -43,7 +43,9 @@ app.post('/graphql-error/graphql', (_req, res, next) => { app.use(express.static(path.resolve(__dirname, '../'))); app.use('index.html', express.static(path.resolve(__dirname, '../dev.html'))); -app.listen(process.env.PORT || 0, function () { +// messy but it allows close +const server = require('node:http').createServer(app); +server.listen(process.env.PORT || 3100, function () { const { port } = this.address(); console.log(`Started on http://localhost:${port}/`); @@ -56,5 +58,7 @@ app.listen(process.env.PORT || 0, function () { process.exit(); }); }); +const wsServer = WebSocketsServer(); -WebSocketsServer(); +module.exports.server = server; +module.exports.wsServer = wsServer; diff --git a/packages/graphql-language-service-server/README.md b/packages/graphql-language-service-server/README.md index d0f5388cd9a..cff8af15e0d 100644 --- a/packages/graphql-language-service-server/README.md +++ b/packages/graphql-language-service-server/README.md @@ -123,6 +123,9 @@ further customization: ```ts import { loadConfig } from 'graphql-config'; // 3.0.0 or later! +// with required params +const config = await loadConfig(); + await startServer({ method: 'node', // or instead of configName, an exact path (relative from rootDir or absolute) @@ -131,7 +134,7 @@ await startServer({ // configDir: '', loadConfigOptions: { // any of the options for graphql-config@3 `loadConfig()` - + schema: await config.getSchema(), // rootDir is same as `configDir` before, the path where the graphql config file would be found by cosmic-config rootDir: 'config/', // or - the relative or absolute path to your file @@ -157,11 +160,29 @@ module.exports = { // note that this file will be loaded by the vscode runtime, so the node version and other factors will come into play customValidationRules: require('./config/customValidationRules'), languageService: { - // should the language service read schema for definition lookups from a cached file based on graphql config output? + // this is enabled by default if non-local files are specified in the project `schema` // NOTE: this will disable all definition lookup for local SDL files cacheSchemaFileForLookup: true, // undefined by default which has the same effect as `true`, set to `false` if you are already using // `graphql-eslint` or some other tool for validating graphql in your IDE. Must be explicitly `false` to disable this feature, not just "falsy" enableValidation: true, + // (experimental) enhanced auto expansion of graphql leaf fields and arguments + fillLeafsOnComplete: true, + // instead of jumping directly to the SDL file, you can override definition peek/jump results to point to different files or locations + // (for example, source files for your schema in any language!) + // based on Relay vscode's pathToLocateCommand + // see LocateCommand type! + locateCommand(projectName, typePath, info) { + // pass more info, such as GraphQLType with the ast node. info.project is also available if you need it + const { path, range } = ourLookupUtility( + projectName, + typePath, + info.type.node, + ); + return { uri: path, range }; // range.start.line/range.end.character/etc, base 1 + // you can also return relay LSP style + // return '/path/to/file.py:20:23'; // (range: 20:1 ) + // return '/path/to/file.py'; // (range: 1:1 1:1) + }, }, }, }; @@ -237,14 +258,14 @@ via `initializationOptions` in nvim.coc. The options are mostly designed to configure graphql-config's load parameters, the only thing we can't configure with graphql config. The final option can be set in `graphql-config` as well -| Parameter | Default | Description | -| ----------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `graphql-config.load.baseDir` | workspace root or process.cwd() | the path where graphql config looks for config files | -| `graphql-config.load.filePath` | `null` | exact filepath of the config file. | -| `graphql-config.load.configName` | `graphql` | config name prefix instead of `graphql` | -| `graphql-config.load.legacy` | `true` | backwards compatibility with `graphql-config@2` | -| `graphql-config.dotEnvPath` | `null` | backwards compatibility with `graphql-config@2` | -| `vscode-graphql.cacheSchemaFileForLookup` | `false` | generate an SDL file based on your graphql-config schema configuration for schema definition lookup and other features. useful when your `schema` config are urls | +| Parameter | Default | Description | +| ----------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `graphql-config.load.baseDir` | workspace root or process.cwd() | the path where graphql config looks for config files | +| `graphql-config.load.filePath` | `null` | exact filepath of the config file. | +| `graphql-config.load.configName` | `graphql` | config name prefix instead of `graphql` | +| `graphql-config.load.legacy` | `true` | backwards compatibility with `graphql-config@2` | +| `graphql-config.dotEnvPath` | `null` | backwards compatibility with `graphql-config@2` | +| `vscode-graphql.cacheSchemaFileForLookup` | `true` if `schema` contains non-sdl files or urls | generate an SDL file based on your graphql-config schema configuration for schema definition lookup and other features. enabled by default when your `schema` config are urls or introspection json, or if you have any non-local SDL files in `schema` | all the `graphql-config.load.*` configuration values come from static `loadConfig()` options in graphql config. diff --git a/packages/graphql-language-service-server/package.json b/packages/graphql-language-service-server/package.json index d4cd4eaceff..c7eba908a12 100644 --- a/packages/graphql-language-service-server/package.json +++ b/packages/graphql-language-service-server/package.json @@ -36,35 +36,37 @@ "peerDependencies": { "graphql": "^15.5.0 || ^16.0.0" }, - "COMMENT": "please do not remove depenencies without thorough testing. many dependencies are not imported directly, as they are peer dependencies", + "COMMENT": "please do not remove dependencies without thorough testing. many dependencies are not imported directly, as they are peer dependencies", "dependencies": { - "@astrojs/compiler": "^2.5.0", + "@astrojs/compiler": "^2.8.0", "@babel/parser": "^7.23.6", "@babel/types": "^7.23.5", "@graphql-tools/code-file-loader": "8.0.3", "@vue/compiler-sfc": "^3.4.5", - "astrojs-compiler-sync": "^0.3.5", + "astrojs-compiler-sync": "^1.0.0", "cosmiconfig-toml-loader": "^1.0.0", "dotenv": "10.0.0", "fast-glob": "^3.2.7", "glob": "^7.2.0", "graphql-config": "5.0.3", "graphql-language-service": "^5.2.0", + "lru-cache": "^10.2.0", "mkdirp": "^1.0.4", "node-abort-controller": "^3.0.1", "nullthrows": "^1.0.0", "source-map-js": "1.0.2", "svelte": "^4.1.1", "svelte2tsx": "^0.7.0", + "typescript": "^5.3.3", "vscode-jsonrpc": "^8.0.1", "vscode-languageserver": "^8.0.1", "vscode-languageserver-types": "^3.17.2", - "vscode-uri": "^3.0.2", - "typescript": "^5.3.3" + "vscode-uri": "^3.0.2" }, "devDependencies": { "@types/glob": "^8.1.0", "@types/mkdirp": "^1.0.1", + "@types/mock-fs": "^4.13.4", "cross-env": "^7.0.2", "graphql": "^16.8.1", "mock-fs": "^5.2.0" diff --git a/packages/graphql-language-service-server/src/GraphQLCache.ts b/packages/graphql-language-service-server/src/GraphQLCache.ts index f7b043e5676..fb3c367b755 100644 --- a/packages/graphql-language-service-server/src/GraphQLCache.ts +++ b/packages/graphql-language-service-server/src/GraphQLCache.ts @@ -20,7 +20,6 @@ import { } from 'graphql'; import type { CachedContent, - GraphQLCache as GraphQLCacheInterface, GraphQLFileMetadata, GraphQLFileInfo, FragmentInfo, @@ -46,18 +45,29 @@ import stringToHash from './stringToHash'; import glob from 'glob'; import { LoadConfigOptions } from './types'; import { URI } from 'vscode-uri'; -import { CodeFileLoader } from '@graphql-tools/code-file-loader'; +import { + CodeFileLoader, + CodeFileLoaderConfig, +} from '@graphql-tools/code-file-loader'; import { DEFAULT_SUPPORTED_EXTENSIONS, DEFAULT_SUPPORTED_GRAPHQL_EXTENSIONS, } from './constants'; import { NoopLogger, Logger } from './Logger'; +import { LRUCache } from 'lru-cache'; + +const codeLoaderConfig: CodeFileLoaderConfig = { + noSilentErrors: false, + pluckConfig: { + skipIndent: true, + }, +}; const LanguageServiceExtension: GraphQLExtensionDeclaration = api => { // For schema - api.loaders.schema.register(new CodeFileLoader()); + api.loaders.schema.register(new CodeFileLoader(codeLoaderConfig)); // For documents - api.loaders.documents.register(new CodeFileLoader()); + api.loaders.documents.register(new CodeFileLoader(codeLoaderConfig)); return { name: 'languageService' }; }; @@ -65,16 +75,20 @@ const LanguageServiceExtension: GraphQLExtensionDeclaration = api => { // Maximum files to read when processing GraphQL files. const MAX_READS = 200; +export type OnSchemaChange = (project: GraphQLProjectConfig) => void; + export async function getGraphQLCache({ parser, logger, loadConfigOptions, config, + onSchemaChange, }: { parser: typeof parseDocument; logger: Logger | NoopLogger; loadConfigOptions: LoadConfigOptions; config?: GraphQLConfig; + onSchemaChange?: OnSchemaChange; }): Promise { const graphQLConfig = config || @@ -90,10 +104,11 @@ export async function getGraphQLCache({ config: graphQLConfig!, parser, logger, + onSchemaChange, }); } -export class GraphQLCache implements GraphQLCacheInterface { +export class GraphQLCache { _configDir: Uri; _graphQLFileListCache: Map>; _graphQLConfig: GraphQLConfig; @@ -103,34 +118,51 @@ export class GraphQLCache implements GraphQLCacheInterface { _typeDefinitionsCache: Map>; _parser: typeof parseDocument; _logger: Logger | NoopLogger; + _onSchemaChange?: OnSchemaChange; constructor({ configDir, config, parser, logger, + onSchemaChange, }: { configDir: Uri; config: GraphQLConfig; parser: typeof parseDocument; logger: Logger | NoopLogger; + onSchemaChange?: OnSchemaChange; }) { this._configDir = configDir; this._graphQLConfig = config; this._graphQLFileListCache = new Map(); - this._schemaMap = new Map(); + this._schemaMap = new LRUCache({ + max: 20, + ttl: 1000 * 30, + ttlAutopurge: true, + updateAgeOnGet: false, + }); this._fragmentDefinitionsCache = new Map(); this._typeDefinitionsCache = new Map(); this._typeExtensionMap = new Map(); this._parser = parser; this._logger = logger; + this._onSchemaChange = onSchemaChange; } getGraphQLConfig = (): GraphQLConfig => this._graphQLConfig; getProjectForFile = (uri: string): GraphQLProjectConfig | void => { try { - return this._graphQLConfig.getProjectForFile(URI.parse(uri).fsPath); + const project = this._graphQLConfig.getProjectForFile( + URI.parse(uri).fsPath, + ); + if (!project.documents) { + this._logger.warn( + `No documents configured for project ${project.name}. Many features will not work correctly.`, + ); + } + return project; } catch (err) { this._logger.error( `there was an error loading the project config for this file ${err}`, @@ -236,29 +268,6 @@ export class GraphQLCache implements GraphQLCacheInterface { return fragmentDefinitions; }; - getObjectTypeDependencies = async ( - query: string, - objectTypeDefinitions?: Map, - ): Promise> => { - // If there isn't context for object type references, - // return an empty array. - if (!objectTypeDefinitions) { - return []; - } - // If the query cannot be parsed, validations cannot happen yet. - // Return an empty array. - let parsedQuery; - try { - parsedQuery = parse(query); - } catch { - return []; - } - return this.getObjectTypeDependenciesForAST( - parsedQuery, - objectTypeDefinitions, - ); - }; - getObjectTypeDependenciesForAST = async ( parsedQuery: ASTNode, objectTypeDefinitions: Map, @@ -424,38 +433,12 @@ export class GraphQLCache implements GraphQLCacheInterface { return patterns; }; - async _updateGraphQLFileListCache( - graphQLFileMap: Map, - metrics: { size: number; mtime: number }, - filePath: Uri, - exists: boolean, - ): Promise> { - const fileAndContent = exists - ? await this.promiseToReadGraphQLFile(filePath) - : null; - - const existingFile = graphQLFileMap.get(filePath); - - // 3 cases for the cache invalidation: create/modify/delete. - // For create/modify, swap the existing entry if available; - // otherwise, just push in the new entry created. - // For delete, check `exists` and splice the file out. - if (existingFile && !exists) { - graphQLFileMap.delete(filePath); - } else if (fileAndContent) { - const graphQLFileInfo = { ...fileAndContent, ...metrics }; - graphQLFileMap.set(filePath, graphQLFileInfo); - } - - return graphQLFileMap; - } - async updateFragmentDefinition( - rootDir: Uri, + projectCacheKey: Uri, filePath: Uri, contents: Array, ): Promise { - const cache = this._fragmentDefinitionsCache.get(rootDir); + const cache = this._fragmentDefinitionsCache.get(projectCacheKey); const asts = contents.map(({ query }) => { try { return { @@ -473,55 +456,44 @@ export class GraphQLCache implements GraphQLCacheInterface { cache.delete(key); } } - for (const { ast, query } of asts) { - if (!ast) { - continue; - } - for (const definition of ast.definitions) { - if (definition.kind === Kind.FRAGMENT_DEFINITION) { - cache.set(definition.name.value, { - filePath, - content: query, - definition, - }); - } - } - } + this._setFragmentCache(asts, cache, filePath); + } else { + const newFragmentCache = this._setFragmentCache( + asts, + new Map(), + filePath, + ); + this._fragmentDefinitionsCache.set(projectCacheKey, newFragmentCache); } } - - async updateFragmentDefinitionCache( - rootDir: Uri, - filePath: Uri, - exists: boolean, - ): Promise { - const fileAndContent = exists - ? await this.promiseToReadGraphQLFile(filePath) - : null; - // In the case of fragment definitions, the cache could just map the - // definition name to the parsed ast, whether or not it existed - // previously. - // For delete, remove the entry from the set. - if (!exists) { - const cache = this._fragmentDefinitionsCache.get(rootDir); - if (cache) { - cache.delete(filePath); + _setFragmentCache( + asts: { ast: DocumentNode | null; query: string }[], + fragmentCache: Map, + filePath: string | undefined, + ) { + for (const { ast, query } of asts) { + if (!ast) { + continue; + } + for (const definition of ast.definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + fragmentCache.set(definition.name.value, { + filePath, + content: query, + definition, + }); + } } - } else if (fileAndContent?.queries) { - await this.updateFragmentDefinition( - rootDir, - filePath, - fileAndContent.queries, - ); } + return fragmentCache; } async updateObjectTypeDefinition( - rootDir: Uri, + projectCacheKey: Uri, filePath: Uri, contents: Array, ): Promise { - const cache = this._typeDefinitionsCache.get(rootDir); + const cache = this._typeDefinitionsCache.get(projectCacheKey); const asts = contents.map(({ query }) => { try { return { @@ -539,47 +511,32 @@ export class GraphQLCache implements GraphQLCacheInterface { cache.delete(key); } } - for (const { ast, query } of asts) { - if (!ast) { - continue; - } - for (const definition of ast.definitions) { - if (isTypeDefinitionNode(definition)) { - cache.set(definition.name.value, { - filePath, - content: query, - definition, - }); - } - } - } + this._setDefinitionCache(asts, cache, filePath); + } else { + const newTypeCache = this._setDefinitionCache(asts, new Map(), filePath); + this._typeDefinitionsCache.set(projectCacheKey, newTypeCache); } } - - async updateObjectTypeDefinitionCache( - rootDir: Uri, - filePath: Uri, - exists: boolean, - ): Promise { - const fileAndContent = exists - ? await this.promiseToReadGraphQLFile(filePath) - : null; - // In the case of type definitions, the cache could just map the - // definition name to the parsed ast, whether or not it existed - // previously. - // For delete, remove the entry from the set. - if (!exists) { - const cache = this._typeDefinitionsCache.get(rootDir); - if (cache) { - cache.delete(filePath); + _setDefinitionCache( + asts: { ast: DocumentNode | null; query: string }[], + typeCache: Map, + filePath: string | undefined, + ) { + for (const { ast, query } of asts) { + if (!ast) { + continue; + } + for (const definition of ast.definitions) { + if (isTypeDefinitionNode(definition)) { + typeCache.set(definition.name.value, { + filePath, + content: query, + definition, + }); + } } - } else if (fileAndContent?.queries) { - await this.updateObjectTypeDefinition( - rootDir, - filePath, - fileAndContent.queries, - ); } + return typeCache; } _extendSchema( @@ -645,10 +602,10 @@ export class GraphQLCache implements GraphQLCacheInterface { } getSchema = async ( - appName?: string, + projectName: string, queryHasExtensions?: boolean | null, ): Promise => { - const projectConfig = this._graphQLConfig.getProject(appName); + const projectConfig = this._graphQLConfig.getProject(projectName); if (!projectConfig) { return null; @@ -663,18 +620,35 @@ export class GraphQLCache implements GraphQLCacheInterface { if (schemaPath && schemaKey) { schemaCacheKey = schemaKey as string; - // Maybe use cache + try { + // Read from disk + schema = await projectConfig.loadSchema( + projectConfig.schema, + 'GraphQLSchema', + { + assumeValid: true, + assumeValidSDL: true, + experimentalFragmentVariables: true, + sort: false, + includeSources: true, + allowLegacySDLEmptyFields: true, + allowLegacySDLImplementsInterfaces: true, + }, + ); + } catch { + // // if there is an error reading the schema, just use the last valid schema + schema = this._schemaMap.get(schemaCacheKey); + } + if (this._schemaMap.has(schemaCacheKey)) { schema = this._schemaMap.get(schemaCacheKey); + if (schema) { return queryHasExtensions ? this._extendSchema(schema, schemaPath, schemaCacheKey) : schema; } } - - // Read from disk - schema = await projectConfig.getSchema(); } const customDirectives = projectConfig?.extensions?.customDirectives; @@ -693,6 +667,7 @@ export class GraphQLCache implements GraphQLCacheInterface { if (schemaCacheKey) { this._schemaMap.set(schemaCacheKey, schema); + await this._onSchemaChange?.(projectConfig); } return schema; }; @@ -713,7 +688,7 @@ export class GraphQLCache implements GraphQLCacheInterface { } _getProjectName(projectConfig: GraphQLProjectConfig) { - return projectConfig || 'default'; + return projectConfig?.name || 'default'; } /** @@ -825,13 +800,13 @@ export class GraphQLCache implements GraphQLCacheInterface { promiseToReadGraphQLFile = async ( filePath: Uri, ): Promise => { - const content = await readFile(URI.parse(filePath).fsPath, 'utf8'); + const content = await readFile(URI.parse(filePath).fsPath, 'utf-8'); const asts: DocumentNode[] = []; let queries: CachedContent[] = []; if (content.trim().length !== 0) { try { - queries = this._parser( + queries = await this._parser( content, filePath, DEFAULT_SUPPORTED_EXTENSIONS, diff --git a/packages/graphql-language-service-server/src/GraphQLLanguageService.ts b/packages/graphql-language-service-server/src/GraphQLLanguageService.ts index aeaa4c92a8e..e2b8c9a31bd 100644 --- a/packages/graphql-language-service-server/src/GraphQLLanguageService.ts +++ b/packages/graphql-language-service-server/src/GraphQLLanguageService.ts @@ -20,6 +20,8 @@ import { parse, print, isTypeDefinitionNode, + ArgumentNode, + typeFromAST, } from 'graphql'; import { @@ -29,7 +31,6 @@ import { IPosition, Outline, OutlineTree, - GraphQLCache, getAutocompleteSuggestions, getHoverInformation, HoverConfig, @@ -41,12 +42,15 @@ import { getDefinitionQueryResultForDefinitionNode, getDefinitionQueryResultForNamedType, getDefinitionQueryResultForField, - DefinitionQueryResult, getASTNodeAtPosition, getTokenAtPosition, getTypeInfo, + DefinitionQueryResponse, + getDefinitionQueryResultForArgument, } from 'graphql-language-service'; +import type { GraphQLCache } from './GraphQLCache'; + import { GraphQLConfig, GraphQLProjectConfig } from 'graphql-config'; import type { Logger } from 'vscode-languageserver'; @@ -223,30 +227,31 @@ export class GraphQLLanguageService { return []; } const schema = await this._graphQLCache.getSchema(projectConfig.name); - const fragmentDefinitions = await this._graphQLCache.getFragmentDefinitions( - projectConfig, - ); + if (!schema) { + return []; + } + let fragmentInfo = [] as Array; + try { + const fragmentDefinitions = + await this._graphQLCache.getFragmentDefinitions(projectConfig); + fragmentInfo = Array.from(fragmentDefinitions).map( + ([, info]) => info.definition, + ); + } catch {} - const fragmentInfo = Array.from(fragmentDefinitions).map( - ([, info]) => info.definition, + return getAutocompleteSuggestions( + schema, + query, + position, + undefined, + fragmentInfo, + { + uri: filePath, + fillLeafsOnComplete: + projectConfig?.extensions?.languageService?.fillLeafsOnComplete ?? + false, + }, ); - - if (schema) { - return getAutocompleteSuggestions( - schema, - query, - position, - undefined, - fragmentInfo, - { - uri: filePath, - fillLeafsOnComplete: - projectConfig?.extensions?.languageService?.fillLeafsOnComplete ?? - false, - }, - ); - } - return []; } public async getHoverInformation( @@ -271,12 +276,17 @@ export class GraphQLLanguageService { query: string, position: IPosition, filePath: Uri, - ): Promise { + ): Promise { const projectConfig = this.getConfigForURI(filePath); + if (!projectConfig) { return null; } + const schema = await this._graphQLCache.getSchema(projectConfig.name); + if (!schema) { + return null; + } let ast; try { ast = parse(query); @@ -285,36 +295,50 @@ export class GraphQLLanguageService { } const node = getASTNodeAtPosition(query, ast, position); + // @ts-expect-error + const type = node && typeFromAST(schema, node); + + let queryResult: DefinitionQueryResponse | null = null; if (node) { switch (node.kind) { case Kind.FRAGMENT_SPREAD: - return this._getDefinitionForFragmentSpread( + queryResult = await this._getDefinitionForFragmentSpread( query, ast, node, filePath, projectConfig, ); - + break; case Kind.FRAGMENT_DEFINITION: case Kind.OPERATION_DEFINITION: - return getDefinitionQueryResultForDefinitionNode( + queryResult = getDefinitionQueryResultForDefinitionNode( filePath, query, node, ); - + break; case Kind.NAMED_TYPE: - return this._getDefinitionForNamedType( + queryResult = await this._getDefinitionForNamedType( query, ast, node, filePath, projectConfig, ); - + break; case Kind.FIELD: - return this._getDefinitionForField( + queryResult = await this._getDefinitionForField( + query, + ast, + node, + filePath, + projectConfig, + position, + ); + break; + case Kind.ARGUMENT: + queryResult = await this._getDefinitionForArgument( query, ast, node, @@ -322,8 +346,16 @@ export class GraphQLLanguageService { projectConfig, position, ); + break; } } + if (queryResult) { + return { + ...queryResult, + node, + type, + }; + } return null; } @@ -382,7 +414,7 @@ export class GraphQLLanguageService { node: NamedTypeNode, filePath: Uri, projectConfig: GraphQLProjectConfig, - ): Promise { + ): Promise { const objectTypeDefinitions = await this._graphQLCache.getObjectTypeDefinitions(projectConfig); @@ -400,13 +432,11 @@ export class GraphQLLanguageService { definition, })); - const result = await getDefinitionQueryResultForNamedType( + return getDefinitionQueryResultForNamedType( query, node, dependencies.concat(localOperationDefinitionInfos), ); - - return result; } async _getDefinitionForField( @@ -432,13 +462,44 @@ export class GraphQLLanguageService { // TODO: need something like getObjectTypeDependenciesForAST? const dependencies = [...objectTypeDefinitions.values()]; - const result = await getDefinitionQueryResultForField( + return getDefinitionQueryResultForField( fieldName, parentTypeName, dependencies, ); + } + + return null; + } + + async _getDefinitionForArgument( + query: string, + _ast: DocumentNode, + _node: ArgumentNode, + _filePath: Uri, + projectConfig: GraphQLProjectConfig, + position: IPosition, + ) { + const token = getTokenAtPosition(query, position); + const schema = await this._graphQLCache.getSchema(projectConfig.name); + + const typeInfo = getTypeInfo(schema!, token.state); + const fieldName = typeInfo.fieldDef?.name; + const argumentName = typeInfo.argDef?.name; + if (typeInfo && fieldName && argumentName) { + const objectTypeDefinitions = + await this._graphQLCache.getObjectTypeDefinitions(projectConfig); - return result; + // TODO: need something like getObjectTypeDependenciesForAST? + const dependencies = [...objectTypeDefinitions.values()]; + + return getDefinitionQueryResultForArgument( + argumentName, + fieldName, + // @ts-expect-error - typeInfo is not typed correctly + typeInfo.argDef?.type?.name, + dependencies, + ); } return null; @@ -450,7 +511,7 @@ export class GraphQLLanguageService { node: FragmentSpreadNode, filePath: Uri, projectConfig: GraphQLProjectConfig, - ): Promise { + ): Promise { const fragmentDefinitions = await this._graphQLCache.getFragmentDefinitions( projectConfig, ); @@ -475,14 +536,13 @@ export class GraphQLLanguageService { }), ); - const result = await getDefinitionQueryResultForFragmentSpread( + return getDefinitionQueryResultForFragmentSpread( query, node, dependencies.concat(localFragInfos), ); - - return result; } + async getOutline(documentText: string): Promise { return getOutline(documentText); } diff --git a/packages/graphql-language-service-server/src/Logger.ts b/packages/graphql-language-service-server/src/Logger.ts index ccc58defa81..85f530f1fd0 100644 --- a/packages/graphql-language-service-server/src/Logger.ts +++ b/packages/graphql-language-service-server/src/Logger.ts @@ -11,7 +11,15 @@ import { Logger as VSCodeLogger } from 'vscode-jsonrpc'; import { Connection } from 'vscode-languageserver'; export class Logger implements VSCodeLogger { - constructor(private _connection: Connection) {} + // TODO: allow specifying exact log level? + // for now this is to handle the debug setting + private logLevel: number; + constructor( + private _connection: Connection, + debug?: boolean, + ) { + this.logLevel = debug ? 1 : 0; + } error(message: string): void { this._connection.console.error(message); @@ -26,7 +34,15 @@ export class Logger implements VSCodeLogger { } log(message: string): void { - this._connection.console.log(message); + if (this.logLevel > 0) { + this._connection.console.log(message); + } + } + set level(level: number) { + this.logLevel = level; + } + get level() { + return this.logLevel; } } @@ -35,4 +51,8 @@ export class NoopLogger implements VSCodeLogger { warn() {} info() {} log() {} + set level(_level: number) {} + get level() { + return 0; + } } diff --git a/packages/graphql-language-service-server/src/MessageProcessor.ts b/packages/graphql-language-service-server/src/MessageProcessor.ts index e871ba4e340..9b8a6e25a09 100644 --- a/packages/graphql-language-service-server/src/MessageProcessor.ts +++ b/packages/graphql-language-service-server/src/MessageProcessor.ts @@ -7,11 +7,9 @@ * */ -import mkdirp from 'mkdirp'; -import { readFileSync, existsSync, writeFileSync } from 'node:fs'; -import { writeFile } from 'node:fs/promises'; +import { existsSync, mkdirSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import * as path from 'node:path'; -import glob from 'fast-glob'; import { URI } from 'vscode-uri'; import { CachedContent, @@ -34,7 +32,6 @@ import type { DidOpenTextDocumentParams, DidChangeConfigurationParams, Diagnostic, - CompletionItem, CompletionList, CancellationToken, Hover, @@ -66,16 +63,18 @@ import { ConfigEmptyError, ConfigInvalidError, ConfigNotFoundError, - GraphQLExtensionDeclaration, LoaderNoResultError, ProjectNotFoundError, } from 'graphql-config'; -import type { LoadConfigOptions } from './types'; +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'; const configDocLink = 'https://www.npmjs.com/package/graphql-language-service-server#user-content-graphql-configuration-file'; @@ -84,29 +83,27 @@ type CachedDocumentType = { version: number; contents: CachedContent[]; }; + function toPosition(position: VscodePosition): IPosition { return new Position(position.line, position.character); } export class MessageProcessor { - _connection: Connection; - _graphQLCache!: GraphQLCache; - _graphQLConfig: GraphQLConfig | undefined; - _languageService!: GraphQLLanguageService; - _textDocumentCache = new Map(); - _isInitialized = false; - _isGraphQLConfigMissing: boolean | null = null; - _willShutdown = false; - _logger: Logger | NoopLogger; - _extensions?: GraphQLExtensionDeclaration[]; - _parser: (text: string, uri: string) => CachedContent[]; - _tmpDir: string; - _tmpUriBase: string; - _tmpDirBase: string; - _loadConfigOptions: LoadConfigOptions; - _schemaCacheInit = false; - _rootPath: string = process.cwd(); - _settings: any; + private _connection: Connection; + private _graphQLCache!: GraphQLCache; + private _languageService!: GraphQLLanguageService; + private _textDocumentCache = new Map(); + private _isInitialized = false; + private _isGraphQLConfigMissing: boolean | null = null; + private _willShutdown = false; + private _logger: Logger | NoopLogger; + private _parser: (text: string, uri: string) => CachedContent[]; + private _tmpDir: string; + private _tmpDirBase: string; + private _loadConfigOptions: LoadConfigOptions; + private _rootPath: string = process.cwd(); + private _settings: any; + private _providedConfig?: GraphQLConfig; constructor({ logger, @@ -127,27 +124,22 @@ export class MessageProcessor { tmpDir?: string; connection: Connection; }) { + if (config) { + this._providedConfig = config; + } this._connection = connection; this._logger = logger; - this._graphQLConfig = config; this._parser = (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'); - this._tmpUriBase = URI.file(this._tmpDirBase).toString(); // use legacy mode by default for backwards compatibility this._loadConfigOptions = { legacy: true, ...loadConfigOptions }; - if ( - loadConfigOptions.extensions && - loadConfigOptions.extensions?.length > 0 - ) { - this._extensions = loadConfigOptions.extensions; - } if (!existsSync(this._tmpDirBase)) { - void mkdirp(this._tmpDirBase); + void mkdirSync(this._tmpDirBase); } } get connection(): Connection { @@ -157,7 +149,7 @@ export class MessageProcessor { this._connection = connection; } - async handleInitializeRequest( + public async handleInitializeRequest( params: InitializeParams, _token?: CancellationToken, configDir?: string, @@ -172,7 +164,7 @@ export class MessageProcessor { documentSymbolProvider: true, completionProvider: { resolveProvider: true, - triggerCharacters: [' ', ':', '$', '(', '@'], + triggerCharacters: [' ', ':', '$', '(', '@', '\n'], }, definitionProvider: true, textDocumentSync: 1, @@ -194,9 +186,6 @@ export class MessageProcessor { 'no rootPath configured in extension or server, defaulting to cwd', ); } - if (!serverCapabilities) { - throw new Error('GraphQL Language Server is not initialized.'); - } this._logger.info( JSON.stringify({ @@ -207,8 +196,8 @@ export class MessageProcessor { return serverCapabilities; } - - async _updateGraphQLConfig() { + // TODO next: refactor (most of) this into the `GraphQLCache` class + async _initializeGraphQLCaches() { const settings = await this._connection.workspace.getConfiguration({ section: 'graphql-config', }); @@ -216,13 +205,18 @@ export class MessageProcessor { const vscodeSettings = await this._connection.workspace.getConfiguration({ section: 'vscode-graphql', }); - if (settings?.dotEnvPath) { - require('dotenv').config({ path: settings.dotEnvPath }); - } + + // 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) => { @@ -234,30 +228,68 @@ export class MessageProcessor { }, this._settings.load ?? {}), rootDir, }; - try { - // reload the graphql cache - this._graphQLCache = await getGraphQLCache({ - parser: this._parser, - loadConfigOptions: this._loadConfigOptions, - logger: this._logger, - }); - this._languageService = new GraphQLLanguageService( - this._graphQLCache, - this._logger, - ); - if (this._graphQLConfig || this._graphQLCache?.getGraphQLConfig) { - const config = - this._graphQLConfig ?? this._graphQLCache.getGraphQLConfig(); + const onSchemaChange = 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); + }; + + 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, + }); + 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, + }); + 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'); } - this._isInitialized = true; } catch (err) { this._handleConfigError({ err }); } } - _handleConfigError({ err }: { err: unknown; uri?: string }) { - // console.log(err, typeof 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 @@ -267,7 +299,7 @@ export class MessageProcessor { // 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', + '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; @@ -288,14 +320,14 @@ export class MessageProcessor { } } - _logConfigError(errorMessage: string) { + 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}`, ); } - async _isGraphQLConfigFile(uri: string) { + private async _isGraphQLConfigFile(uri: string) { const configMatchers = ['graphql.config', 'graphqlrc', 'graphqlconfig']; if (this._settings?.load?.fileName?.length) { configMatchers.push(this._settings.load.fileName); @@ -308,34 +340,54 @@ export class MessageProcessor { return fileMatch; } if (uri.match('package.json')?.length) { - const graphqlConfig = await import(URI.parse(uri).fsPath); - return Boolean(graphqlConfig?.graphql); + try { + const pkgConfig = await readFile(URI.parse(uri).fsPath, 'utf-8'); + return Boolean(JSON.parse(pkgConfig)?.graphql); + } catch {} } return false; } - - async handleDidOpenOrSaveNotification( - params: DidSaveTextDocumentParams | DidOpenTextDocumentParams, - ): Promise { - /** - * Initialize the LSP server when the first file is opened or saved, - * so that we can access the user settings for config rootDir, etc - */ - const isGraphQLConfigFile = await this._isGraphQLConfigFile( - params.textDocument.uri, - ); + private async _loadConfigOrSkip(uri: string) { try { - if (!this._isInitialized || !this._graphQLCache) { - // don't try to initialize again if we've already tried - // and the graphql config file or package.json entry isn't even there + const isGraphQLConfigFile = await this._isGraphQLConfigFile(uri); + + if (!this._isInitialized) { if (this._isGraphQLConfigMissing === true && !isGraphQLConfigFile) { - return null; + return true; } - // then initial call to update graphql config - await this._updateGraphQLConfig(); + // 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 { + 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, @@ -344,55 +396,46 @@ export class MessageProcessor { // We aren't able to use initialization event for this // and the config change event is after the fact. - if (!params?.textDocument) { + if (!textDocument) { throw new Error('`textDocument` argument is required.'); } - const { textDocument } = params; - const { uri } = textDocument; const diagnostics: Diagnostic[] = []; - let contents: CachedContent[] = []; - const text = 'text' in textDocument && textDocument.text; - // Create/modify the cached entry if text is provided. - // Otherwise, try searching the cache to perform diagnostics. - if (text) { - // textDocument/didSave does not pass in the text content. - // Only run the below function if text is passed in. - contents = this._parser(text, uri); - - await this._invalidateCache(textDocument, uri, contents); - } else { - if (isGraphQLConfigFile) { - this._logger.info('updating graphql config'); - await this._updateGraphQLConfig(); - return { uri, diagnostics: [] }; - } - return null; - } - if (!this._graphQLCache) { + if (!this._isInitialized) { return { uri, diagnostics }; } try { const project = this._graphQLCache.getProjectForFile(uri); - if ( - this._isInitialized && - 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), - ); - } - }), + + 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( @@ -403,14 +446,14 @@ export class MessageProcessor { fileName: uri, }), ); + return { uri, diagnostics }; } catch (err) { this._handleConfigError({ err, uri }); + return { uri, diagnostics }; } - - return { uri, diagnostics }; } - async handleDidChangeNotification( + public async handleDidChangeNotification( params: DidChangeTextDocumentParams, ): Promise { if ( @@ -431,46 +474,47 @@ export class MessageProcessor { } const { textDocument, contentChanges } = params; const { uri } = textDocument; - const project = this._graphQLCache.getProjectForFile(uri); + try { - const contentChange = contentChanges.at(-1)!; + 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 = this._parser(contentChange.text, uri); - // If it's a .graphql file, proceed normally and invalidate the cache. - await this._invalidateCache(textDocument, uri, contents); - - const cachedDocument = this._getCachedDocument(uri); - - if (!cachedDocument) { - return null; - } - - await this._updateFragmentDefinition(uri, contents); - await this._updateObjectTypeDefinition(uri, contents); + 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 - 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), + 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( @@ -491,7 +535,7 @@ export class MessageProcessor { async handleDidChangeConfiguration( _params: DidChangeConfigurationParams, ): Promise { - await this._updateGraphQLConfig(); + await this._initializeGraphQLCaches(); this._logger.log( JSON.stringify({ type: 'usage', @@ -501,8 +545,8 @@ export class MessageProcessor { return {}; } - handleDidCloseNotification(params: DidCloseTextDocumentParams): void { - if (!this._isInitialized || !this._graphQLCache) { + public handleDidCloseNotification(params: DidCloseTextDocumentParams): void { + if (!this._isInitialized) { return; } // For every `textDocument/didClose` event, delete the cached entry. @@ -529,15 +573,15 @@ export class MessageProcessor { ); } - handleShutdownRequest(): void { + public handleShutdownRequest(): void { this._willShutdown = true; } - handleExitNotification(): void { + public handleExitNotification(): void { process.exit(this._willShutdown ? 0 : 1); } - validateDocumentAndPosition(params: CompletionParams): void { + private validateDocumentAndPosition(params: CompletionParams): void { if (!params?.textDocument?.uri || !params.position) { throw new Error( '`textDocument.uri` and `position` arguments are required.', @@ -545,11 +589,11 @@ export class MessageProcessor { } } - async handleCompletionRequest( + public async handleCompletionRequest( params: CompletionParams, - ): Promise> { - if (!this._isInitialized || !this._graphQLCache) { - return []; + ): Promise { + if (!this._isInitialized) { + return { items: [], isIncomplete: false }; } this.validateDocumentAndPosition(params); @@ -563,7 +607,7 @@ export class MessageProcessor { const cachedDocument = this._getCachedDocument(textDocument.uri); if (!cachedDocument) { - return []; + return { items: [], isIncomplete: false }; } const found = cachedDocument.contents.find(content => { @@ -575,7 +619,7 @@ export class MessageProcessor { // If there is no GraphQL query in this file, return an empty result. if (!found) { - return []; + return { items: [], isIncomplete: false }; } const { query, range } = found; @@ -583,6 +627,7 @@ export class MessageProcessor { if (range) { position.line -= range.start.line; } + const result = await this._languageService.getAutocompleteSuggestions( query, toPosition(position), @@ -603,8 +648,10 @@ export class MessageProcessor { return { items: result, isIncomplete: false }; } - async handleHoverRequest(params: TextDocumentPositionParams): Promise { - if (!this._isInitialized || !this._graphQLCache) { + public async handleHoverRequest( + params: TextDocumentPositionParams, + ): Promise { + if (!this._isInitialized) { return { contents: [] }; } @@ -646,26 +693,34 @@ export class MessageProcessor { }; } - async handleWatchedFilesChangedNotification( - params: DidChangeWatchedFilesParams, - ): Promise | null> { - if ( - this._isGraphQLConfigMissing || - !this._isInitialized || - !this._graphQLCache - ) { - return null; + 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 }; } + } - return Promise.all( + public async handleWatchedFilesChangedNotification( + params: DidChangeWatchedFilesParams, + ): Promise | null> { + const resultsForChanges = Promise.all( params.changes.map(async (change: FileEvent) => { - if ( - this._isGraphQLConfigMissing || - !this._isInitialized || - !this._graphQLCache - ) { - this._logger.warn('No cache available for handleWatchedFilesChanged'); - return; + const shouldSkip = await this._loadConfigOrSkip(change.uri); + if (shouldSkip) { + return { uri: change.uri, diagnostics: [] }; } if ( change.type === FileChangeTypeKind.Created || @@ -673,75 +728,60 @@ export class MessageProcessor { ) { const { uri } = change; - const text = readFileSync(URI.parse(uri).fsPath, 'utf-8'); - const contents = this._parser(text, uri); - - await this._updateFragmentDefinition(uri, contents); - await this._updateObjectTypeDefinition(uri, contents); - try { + let diagnostics: Diagnostic[] = []; const project = this._graphQLCache.getProjectForFile(uri); if (project) { - await this._updateSchemaIfChanged(project, uri); + // 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 }; } - - let diagnostics: Diagnostic[] = []; - - 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); - } - - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'workspace/didChangeWatchedFiles', - projectName: project?.name, - fileName: uri, - }), - ); - return { uri, diagnostics }; - } catch (err) { - this._handleConfigError({ err, uri }); - return { uri, diagnostics: [] }; - } + // skip diagnostics errors usually from incomplete files + } catch {} + return { uri, diagnostics: [] }; } if (change.type === FileChangeTypeKind.Deleted) { - await this._graphQLCache.updateFragmentDefinitionCache( - this._graphQLCache.getGraphQLConfig().dirpath, - change.uri, - false, - ); - await this._graphQLCache.updateObjectTypeDefinitionCache( - this._graphQLCache.getGraphQLConfig().dirpath, - change.uri, - false, - ); + 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; } - async handleDefinitionRequest( + public async handleDefinitionRequest( params: TextDocumentPositionParams, _token?: CancellationToken, ): Promise> { - if (!this._isInitialized || !this._graphQLCache) { + if (!this._isInitialized) { return []; } @@ -750,11 +790,8 @@ export class MessageProcessor { } const { textDocument, position } = params; const project = this._graphQLCache.getProjectForFile(textDocument.uri); - if (project) { - await this._cacheSchemaFilesForProject(project); - } const cachedDocument = this._getCachedDocument(textDocument.uri); - if (!cachedDocument) { + if (!cachedDocument || !project) { return []; } @@ -775,7 +812,7 @@ export class MessageProcessor { position.line -= parentRange.start.line; } - let result = null; + let result: DefinitionQueryResponse | null = null; try { result = await this._languageService.getDefinition( @@ -796,17 +833,26 @@ export class MessageProcessor { }); } 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(textDocument.uri) as SupportedExtensionsEnum, + path.extname(res.path) as SupportedExtensionsEnum, ); - if (isInline && isEmbedded) { - const vOffset = parentRange.start.line; + + 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, @@ -817,10 +863,22 @@ export class MessageProcessor { ); } } + + if (locateCommand && result && result?.printedName) { + const locateResult = this._getCustomLocateResult( + project, + result, + locateCommand, + ); + + if (locateResult) { + return locateResult; + } + } return { uri: res.path, range: defRange, - } as Location; + }; }) : []; @@ -834,11 +892,44 @@ export class MessageProcessor { ); 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; + } + } - async handleDocumentSymbolRequest( + public async handleDocumentSymbolRequest( params: DocumentSymbolParams, ): Promise> { - if (!this._isInitialized || !this._graphQLCache) { + if (!this._isInitialized) { return []; } @@ -895,14 +986,12 @@ export class MessageProcessor { // ); // } - async handleWorkspaceSymbolRequest( + public async handleWorkspaceSymbolRequest( params: WorkspaceSymbolParams, ): Promise> { - if (!this._isInitialized || !this._graphQLCache) { + if (!this._isInitialized) { return []; } - // const config = await this._graphQLCache.getGraphQLConfig(); - // await this._cacheAllProjectFiles(config); if (params.query !== '') { const documents = this._getTextDocuments(); @@ -910,6 +999,7 @@ export class MessageProcessor { await Promise.all( documents.map(async ([uri]) => { const cachedDocument = this._getCachedDocument(uri); + if (!cachedDocument) { return []; } @@ -928,41 +1018,54 @@ export class MessageProcessor { return []; } - _getTextDocuments() { + private _getTextDocuments() { return Array.from(this._textDocumentCache); } - async _cacheSchemaText(uri: string, text: string, version: number) { + private async _cacheSchemaText( + uri: string, + text: string, + version: number, + project?: GraphQLProjectConfig, + ) { try { - const contents = this._parser(text, uri); + const contents = await this._parser(text, uri); if (contents.length > 0) { await this._invalidateCache({ version, uri }, uri, contents); - await this._updateObjectTypeDefinition(uri, contents); + await this._updateObjectTypeDefinition(uri, contents, project); } } catch (err) { this._logger.error(String(err)); } } - async _cacheSchemaFile( - _uri: UnnormalizedTypeDefPointer, + private async _cacheSchemaFile( + fileUri: UnnormalizedTypeDefPointer, project: GraphQLProjectConfig, ) { - const uri = _uri.toString(); - - const isFileUri = existsSync(uri); - let version = 1; - if (isFileUri) { - const schemaUri = URI.file(path.join(project.dirpath, uri)).toString(); - const schemaDocument = this._getCachedDocument(schemaUri); - - if (schemaDocument) { - version = schemaDocument.version++; + 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); } - const schemaText = readFileSync(uri, 'utf8'); - await this._cacheSchemaText(schemaUri, schemaText, version); + } catch (err) { + this._logger.error(String(err)); } } - _getTmpProjectPath( + private _getTmpProjectPath( project: GraphQLProjectConfig, prependWithProtocol = true, appendPath?: string, @@ -972,7 +1075,9 @@ export class MessageProcessor { const basePath = path.join(this._tmpDirBase, workspaceName); let projectTmpPath = path.join(basePath, 'projects', project.name); if (!existsSync(projectTmpPath)) { - void mkdirp(projectTmpPath); + mkdirSync(projectTmpPath, { + recursive: true, + }); } if (appendPath) { projectTmpPath = path.join(projectTmpPath, appendPath); @@ -982,57 +1087,30 @@ export class MessageProcessor { } return path.resolve(projectTmpPath); } - /** - * Safely attempts to cache schema files based on a glob or path - * Exits without warning in several cases because these strings can be almost - * anything! - * @param uri - * @param project - */ - async _cacheSchemaPath(uri: string, project: GraphQLProjectConfig) { - try { - const files = await glob(uri); - if (files && files.length > 0) { - await Promise.all( - files.map(uriPath => this._cacheSchemaFile(uriPath, project)), - ); - } else { - try { - await this._cacheSchemaFile(uri, project); - } catch { - // this string may be an SDL string even, how do we even evaluate this? - } - } - } catch {} - } - async _cacheObjectSchema( - pointer: { [key: string]: any }, - project: GraphQLProjectConfig, - ) { - await Promise.all( - Object.keys(pointer).map(async schemaUri => - this._cacheSchemaPath(schemaUri, project), - ), - ); - } - async _cacheArraySchema( - pointers: UnnormalizedTypeDefPointer[], - project: GraphQLProjectConfig, - ) { - await Promise.all( - pointers.map(async schemaEntry => { - if (typeof schemaEntry === 'string') { - await this._cacheSchemaPath(schemaEntry, project); - } else if (schemaEntry) { - await this._cacheObjectSchema(schemaEntry, project); - } - }), - ); - } - async _cacheSchemaFilesForProject(project: GraphQLProjectConfig) { - const schema = project?.schema; + 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 * @@ -1047,18 +1125,39 @@ export class MessageProcessor { * * it is disabled by default */ - const cacheSchemaFileForLookup = - config?.cacheSchemaFileForLookup ?? - this?._settings?.cacheSchemaFileForLookup ?? - false; - if (cacheSchemaFileForLookup) { - await this._cacheConfigSchema(project); - } else if (typeof schema === 'string') { - await this._cacheSchemaPath(schema, project); - } else if (Array.isArray(schema)) { - await this._cacheArraySchema(schema, project); - } else if (schema) { - await this._cacheObjectSchema(schema, project); + 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), + ), + ); } } /** @@ -1066,7 +1165,7 @@ export class MessageProcessor { * from GraphQLCache.getSchema() * @param project {GraphQLProjectConfig} */ - async _cacheConfigSchema(project: GraphQLProjectConfig) { + private async cacheConfigSchemaFile(project: GraphQLProjectConfig) { try { const schema = await this._graphQLCache.getSchema(project.name); if (schema) { @@ -1087,18 +1186,25 @@ export class MessageProcessor { 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, 'utf8'); - await this._cacheSchemaText(uri, schemaText, 1); + 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) { - writeFileSync(fsPath, schemaText, 'utf8'); + await writeFile(fsPath, schemaText, 'utf-8'); await this._cacheSchemaText( uri, schemaText, cachedSchemaDoc.version++, + project, ); } } @@ -1113,7 +1219,7 @@ export class MessageProcessor { * * @param project {GraphQLProjectConfig} */ - async _cacheDocumentFilesforProject(project: GraphQLProjectConfig) { + private async _cacheDocumentFilesforProject(project: GraphQLProjectConfig) { try { const documents = await project.getDocuments(); return Promise.all( @@ -1131,7 +1237,7 @@ export class MessageProcessor { const uri = URI.file(filePath).toString(); // I would use the already existing graphql-config AST, but there are a few reasons we can't yet - const contents = this._parser(document.rawSDL, uri); + const contents = await this._parser(document.rawSDL, uri); if (!contents[0]?.query) { return; } @@ -1152,13 +1258,24 @@ export class MessageProcessor { * Caching all the document files upfront could be expensive. * @param config {GraphQLConfig} */ - async _cacheAllProjectFiles(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); - await this._cacheDocumentFilesforProject(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'), + ); + } }), ); } @@ -1169,61 +1286,60 @@ export class MessageProcessor { ); } - async _updateFragmentDefinition( + private async _updateFragmentDefinition( uri: Uri, contents: CachedContent[], ): Promise { - const rootDir = this._graphQLCache.getGraphQLConfig().dirpath; - - await this._graphQLCache.updateFragmentDefinition(rootDir, uri, contents); + const project = this._graphQLCache.getProjectForFile(uri); + if (project) { + const cacheKey = this._graphQLCache._cacheKeyForProject(project); + await this._graphQLCache.updateFragmentDefinition( + cacheKey, + uri, + contents, + ); + } } - async _updateSchemaIfChanged( + private async _updateSchemaIfChanged( project: GraphQLProjectConfig, uri: Uri, ): Promise { await Promise.all( - this._unwrapProjectSchema(project).map(async schema => { + unwrapProjectSchema(project).map(async schema => { const schemaFilePath = path.resolve(project.dirpath, schema); const uriFilePath = URI.parse(uri).fsPath; if (uriFilePath === schemaFilePath) { - await this._graphQLCache.invalidateSchemaCacheForProject(project); + 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 {} } }), ); } - _unwrapProjectSchema(project: GraphQLProjectConfig): string[] { - const projectSchema = project.schema; - - const schemas: string[] = []; - if (typeof projectSchema === 'string') { - schemas.push(projectSchema); - } else if (Array.isArray(projectSchema)) { - for (const schemaEntry of projectSchema) { - if (typeof schemaEntry === 'string') { - schemas.push(schemaEntry); - } else if (schemaEntry) { - schemas.push(...Object.keys(schemaEntry)); - } - } - } else { - schemas.push(...Object.keys(projectSchema)); - } - - return schemas; - } - - async _updateObjectTypeDefinition( + private async _updateObjectTypeDefinition( uri: Uri, contents: CachedContent[], + project?: GraphQLProjectConfig, ): Promise { - const rootDir = this._graphQLCache.getGraphQLConfig().dirpath; - - await this._graphQLCache.updateObjectTypeDefinition(rootDir, uri, contents); + const resolvedProject = + project ?? (await this._graphQLCache.getProjectForFile(uri)); + if (resolvedProject) { + const cacheKey = this._graphQLCache._cacheKeyForProject(resolvedProject); + await this._graphQLCache.updateObjectTypeDefinition( + cacheKey, + uri, + contents, + ); + } } - _getCachedDocument(uri: string): CachedDocumentType | null { + private _getCachedDocument(uri: string): CachedDocumentType | null { if (this._textDocumentCache.has(uri)) { const cachedDocument = this._textDocumentCache.get(uri); if (cachedDocument) { @@ -1233,7 +1349,7 @@ export class MessageProcessor { return null; } - async _invalidateCache( + private async _invalidateCache( textDocument: VersionedTextDocumentIdentifier, uri: Uri, contents: CachedContent[], @@ -1261,7 +1377,7 @@ export class MessageProcessor { } } -function processDiagnosticsMessage( +export function processDiagnosticsMessage( results: Diagnostic[], query: string, range: RangeType | null, diff --git a/packages/graphql-language-service-server/src/__tests__/GraphQLCache-test.ts b/packages/graphql-language-service-server/src/__tests__/GraphQLCache.test.ts similarity index 100% rename from packages/graphql-language-service-server/src/__tests__/GraphQLCache-test.ts rename to packages/graphql-language-service-server/src/__tests__/GraphQLCache.test.ts diff --git a/packages/graphql-language-service-server/src/__tests__/GraphQLLanguageService-test.ts b/packages/graphql-language-service-server/src/__tests__/GraphQLLanguageService.test.ts similarity index 78% rename from packages/graphql-language-service-server/src/__tests__/GraphQLLanguageService-test.ts rename to packages/graphql-language-service-server/src/__tests__/GraphQLLanguageService.test.ts index 0283c277174..93dc8e4525e 100644 --- a/packages/graphql-language-service-server/src/__tests__/GraphQLLanguageService-test.ts +++ b/packages/graphql-language-service-server/src/__tests__/GraphQLLanguageService.test.ts @@ -14,6 +14,7 @@ import { GraphQLLanguageService } from '../GraphQLLanguageService'; import { SymbolKind } from 'vscode-languageserver-protocol'; import { Position } from 'graphql-language-service'; import { NoopLogger } from '../Logger'; +import { GraphQLEnumType } from 'graphql'; const MOCK_CONFIG = { filepath: join(__dirname, '.graphqlrc.yml'), @@ -37,7 +38,29 @@ describe('GraphQLLanguageService', () => { getProjectForFile(uri: string) { return this.getGraphQLConfig().getProjectForFile(uri); }, - + getFragmentDefinitions() { + const definitions = new Map(); + definitions.set('TestFragment', { + filePath: 'fake file path', + content: 'fake file content', + definition: { + kind: 'FragmentDefinition', + name: { + value: 'TestFragment', + }, + loc: { + start: 293, + end: 335, + }, + }, + }); + return definitions; + }, + // setting the defs here in duplicate as with object types below + // leads to duplicates, perhaps related to a bug, or perhaps just a test bug? + getFragmentDependenciesForAST() { + return []; + }, getObjectTypeDefinitions() { const definitions = new Map(); @@ -71,6 +94,16 @@ describe('GraphQLLanguageService', () => { start: 293, end: 335, }, + arguments: [ + { + name: { value: 'arg' }, + loc: { + start: 293, + end: 335, + }, + type: GraphQLEnumType, + }, + ], }, ], @@ -166,6 +199,23 @@ describe('GraphQLLanguageService', () => { expect(definitionQueryResult?.definitions.length).toEqual(1); }); + it('runs definition service on fragment definition', async () => { + const definitionQueryResult = await languageService.getDefinition( + 'fragment TestFragment on Human { name }', + { line: 0, character: 14 } as Position, + './queries/definitionQuery.graphql', + ); + expect(definitionQueryResult?.definitions.length).toEqual(1); + }); + it('runs definition service on fragment spread', async () => { + const definitionQueryResult = await languageService.getDefinition( + 'fragment TestFragment on Human { name }\nquery { ...TestFragment }', + { line: 1, character: 14 } as Position, + './queries/definitionQuery.graphql', + ); + expect(definitionQueryResult?.definitions.length).toEqual(1); + }); + it('runs definition service on field as expected', async () => { const definitionQueryResult = await languageService.getDefinition( 'query XXX { human { name } }', diff --git a/packages/graphql-language-service-server/src/__tests__/Logger.test.ts b/packages/graphql-language-service-server/src/__tests__/Logger.test.ts new file mode 100644 index 00000000000..82ac05fd097 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/Logger.test.ts @@ -0,0 +1,39 @@ +import { Logger } from '../Logger'; + +describe('Logger', () => { + const connection = { + console: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + log: jest.fn(), + }, + onDidChangeConfiguration: jest.fn(), + workspace: { + getConfiguration: jest.fn(), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize with default log level, and ignore .log intentionally', () => { + const logger = new Logger(connection as any); + expect(logger).toBeDefined(); + expect(logger.logLevel).toBe(0); + logger.log('test'); + expect(connection.console.log).toHaveBeenCalledTimes(0); + }); + + it('should not change log level when settings are not passed', () => { + const logger = new Logger(connection as any, true); + expect(logger).toBeDefined(); + expect(logger.logLevel).toBe(1); + logger.log('test'); + expect(connection.console.log).toHaveBeenCalledTimes(1); + expect(logger.logLevel).toBe(1); + logger.log('test'); + expect(connection.console.log).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts b/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts deleted file mode 100644 index e2c2ecdaaf9..00000000000 --- a/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts +++ /dev/null @@ -1,601 +0,0 @@ -/** - * 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. - * - */ -import { SymbolKind } from 'vscode-languageserver'; -import { FileChangeType } from 'vscode-languageserver-protocol'; -import { Position, Range } from 'graphql-language-service'; - -import { MessageProcessor } from '../MessageProcessor'; -import { parseDocument } from '../parseDocument'; - -jest.mock('../Logger'); - -import { GraphQLCache } from '../GraphQLCache'; - -import { loadConfig } from 'graphql-config'; - -import type { DefinitionQueryResult, Outline } from 'graphql-language-service'; - -import { NoopLogger } from '../Logger'; -import { pathToFileURL } from 'node:url'; -import mockfs from 'mock-fs'; -import { join } from 'node:path'; - -jest.mock('node:fs', () => ({ - ...jest.requireActual('fs'), - readFileSync: jest.fn(jest.requireActual('fs').readFileSync), -})); - -describe('MessageProcessor', () => { - const logger = new NoopLogger(); - const messageProcessor = new MessageProcessor({ - // @ts-ignore - connection: {}, - logger, - graphqlFileExtensions: ['graphql'], - loadConfigOptions: { rootDir: __dirname }, - }); - - const queryPathUri = pathToFileURL(`${__dirname}/__queries__`); - const textDocumentTestString = ` - { - hero(episode: NEWHOPE){ - } - } - `; - - beforeEach(async () => { - const gqlConfig = await loadConfig({ rootDir: __dirname, extensions: [] }); - // loadConfig.mockRestore(); - messageProcessor._settings = { load: {} }; - messageProcessor._graphQLCache = new GraphQLCache({ - configDir: __dirname, - config: gqlConfig, - parser: parseDocument, - logger: new NoopLogger(), - }); - messageProcessor._languageService = { - // @ts-ignore - getAutocompleteSuggestions(query, position, uri) { - return [{ label: `${query} at ${uri}` }]; - }, - // @ts-ignore - getDiagnostics(_query, _uri) { - return []; - }, - async getDocumentSymbols(_query: string, uri: string) { - return [ - { - name: 'item', - kind: SymbolKind.Field, - location: { - uri, - range: { - start: { line: 1, character: 2 }, - end: { line: 1, character: 4 }, - }, - }, - }, - ]; - }, - async getOutline(_query: string): Promise { - return { - outlineTrees: [ - { - representativeName: 'item', - kind: 'Field', - startPosition: new Position(1, 2), - endPosition: new Position(1, 4), - children: [], - }, - ], - }; - }, - async getDefinition( - _query, - position, - uri, - ): Promise { - return { - queryRange: [new Range(position, position)], - definitions: [ - { - position, - path: uri, - }, - ], - }; - }, - }; - }); - - let getConfigurationReturnValue = {}; - // @ts-ignore - messageProcessor._connection = { - // @ts-ignore - get workspace() { - return { - async getConfiguration() { - return [getConfigurationReturnValue]; - }, - }; - }, - }; - - const initialDocument = { - textDocument: { - text: textDocumentTestString, - uri: `${queryPathUri}/test.graphql`, - version: 0, - }, - }; - - messageProcessor._isInitialized = true; - - it('initializes properly and opens a file', async () => { - const { capabilities } = await messageProcessor.handleInitializeRequest( - // @ts-ignore - { - rootPath: __dirname, - }, - null, - __dirname, - ); - expect(capabilities.definitionProvider).toEqual(true); - expect(capabilities.workspaceSymbolProvider).toEqual(true); - expect(capabilities.completionProvider.resolveProvider).toEqual(true); - expect(capabilities.textDocumentSync).toEqual(1); - }); - - it('runs completion requests properly', async () => { - const uri = `${queryPathUri}/test2.graphql`; - const query = 'test'; - messageProcessor._textDocumentCache.set(uri, { - version: 0, - contents: [ - { - query, - range: new Range(new Position(0, 0), new Position(0, 0)), - }, - ], - }); - - const test = { - position: new Position(0, 0), - textDocument: { uri }, - }; - const result = await messageProcessor.handleCompletionRequest(test); - expect(result).toEqual({ - items: [{ label: `${query} at ${uri}` }], - isIncomplete: false, - }); - }); - - it('runs document symbol requests', async () => { - const uri = `${queryPathUri}/test3.graphql`; - const validQuery = ` - { - hero(episode: EMPIRE){ - ...testFragment - } - } - `; - - const newDocument = { - textDocument: { - text: validQuery, - uri, - version: 0, - }, - }; - - messageProcessor._textDocumentCache.set(uri, { - version: 0, - contents: [ - { - query: validQuery, - range: new Range(new Position(0, 0), new Position(0, 0)), - }, - ], - }); - - const test = { - textDocument: newDocument.textDocument, - }; - - const result = await messageProcessor.handleDocumentSymbolRequest(test); - - expect(result).not.toBeUndefined(); - expect(result.length).toEqual(1); - expect(result[0].name).toEqual('item'); - expect(result[0].kind).toEqual(SymbolKind.Field); - expect(result[0].location.range).toEqual({ - start: { line: 1, character: 2 }, - end: { line: 1, character: 4 }, - }); - }); - - it('properly changes the file cache with the didChange handler', async () => { - const uri = `${queryPathUri}/test.graphql`; - messageProcessor._textDocumentCache.set(uri, { - version: 1, - contents: [ - { - query: '', - range: new Range(new Position(0, 0), new Position(0, 0)), - }, - ], - }); - const textDocumentChangedString = ` - { - hero(episode: NEWHOPE){ - name - } - } - `; - - const result = await messageProcessor.handleDidChangeNotification({ - textDocument: { - // @ts-ignore - text: textDocumentTestString, - uri, - version: 1, - }, - contentChanges: [ - { text: textDocumentTestString }, - { text: textDocumentChangedString }, - ], - }); - // Query fixed, no more errors - expect(result.diagnostics.length).toEqual(0); - }); - - it('does not crash on null value returned in response to workspace configuration', async () => { - const previousConfigurationValue = getConfigurationReturnValue; - getConfigurationReturnValue = null; - await expect( - messageProcessor.handleDidChangeConfiguration(), - ).resolves.toStrictEqual({}); - getConfigurationReturnValue = previousConfigurationValue; - }); - - it('properly removes from the file cache with the didClose handler', async () => { - await messageProcessor.handleDidCloseNotification(initialDocument); - - const position = { line: 4, character: 5 }; - const params = { textDocument: initialDocument.textDocument, position }; - - // Should throw because file has been deleted from cache - try { - const result = await messageProcessor.handleCompletionRequest(params); - expect(result).toEqual(null); - } catch {} - }); - - // modified to work with jest.mock() of WatchmanClient - it('runs definition requests', async () => { - jest.setTimeout(10000); - const validQuery = ` - { - hero(episode: EMPIRE){ - ...testFragment - } - } - `; - - const newDocument = { - textDocument: { - text: validQuery, - uri: `${queryPathUri}/test3.graphql`, - version: 1, - }, - }; - messageProcessor._getCachedDocument = (_uri: string) => ({ - version: 1, - contents: [ - { - query: validQuery, - range: new Range(new Position(0, 0), new Position(20, 4)), - }, - ], - }); - - await messageProcessor.handleDidOpenOrSaveNotification(newDocument); - - const test = { - position: new Position(3, 15), - textDocument: newDocument.textDocument, - }; - - const result = await messageProcessor.handleDefinitionRequest(test); - await expect(result[0].uri).toEqual(`${queryPathUri}/test3.graphql`); - }); - - describe('handleDidOpenOrSaveNotification', () => { - const mockReadFileSync: jest.Mock = - jest.requireMock('node:fs').readFileSync; - - beforeEach(() => { - mockReadFileSync.mockReturnValue(''); - messageProcessor._updateGraphQLConfig = jest.fn(); - }); - it('updates config for standard config filename changes', async () => { - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - uri: `${pathToFileURL('.')}/.graphql.config.js`, - languageId: 'js', - version: 0, - text: '', - }, - }); - - expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); - }); - - it('updates config for custom config filename changes', async () => { - const customConfigName = 'custom-config-name.yml'; - messageProcessor._settings = { load: { fileName: customConfigName } }; - - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - uri: `${pathToFileURL('.')}/${customConfigName}`, - languageId: 'js', - version: 0, - text: '', - }, - }); - - expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); - }); - - it('handles config requests with no config', async () => { - messageProcessor._settings = {}; - - await messageProcessor.handleDidChangeConfiguration({ - settings: [], - }); - - expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); - - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - uri: `${pathToFileURL('.')}/.graphql.config.js`, - languageId: 'js', - version: 0, - text: '', - }, - }); - - expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); - }); - }); - - describe('handleWatchedFilesChangedNotification', () => { - const mockReadFileSync: jest.Mock = - jest.requireMock('node:fs').readFileSync; - - beforeEach(() => { - mockReadFileSync.mockReturnValue(''); - messageProcessor._updateGraphQLConfig = jest.fn(); - }); - - it('skips config updates for normal file changes', async () => { - await messageProcessor.handleWatchedFilesChangedNotification({ - changes: [ - { - uri: `${pathToFileURL('.')}/foo.graphql`, - type: FileChangeType.Changed, - }, - ], - }); - - expect(messageProcessor._updateGraphQLConfig).not.toHaveBeenCalled(); - }); - }); - - describe('handleWatchedFilesChangedNotification without graphql config', () => { - const mockReadFileSync: jest.Mock = - jest.requireMock('node:fs').readFileSync; - - beforeEach(() => { - mockReadFileSync.mockReturnValue(''); - messageProcessor._graphQLConfig = undefined; - messageProcessor._isGraphQLConfigMissing = true; - messageProcessor._parser = jest.fn(); - }); - - it('skips config updates for normal file changes', async () => { - await messageProcessor.handleWatchedFilesChangedNotification({ - changes: [ - { - uri: `${pathToFileURL('.')}/foo.js`, - type: FileChangeType.Changed, - }, - ], - }); - expect(messageProcessor._parser).not.toHaveBeenCalled(); - }); - }); - - describe('handleDidChangedNotification without graphql config', () => { - const mockReadFileSync: jest.Mock = - jest.requireMock('node:fs').readFileSync; - - beforeEach(() => { - mockReadFileSync.mockReturnValue(''); - messageProcessor._graphQLConfig = undefined; - messageProcessor._isGraphQLConfigMissing = true; - messageProcessor._parser = jest.fn(); - }); - - it('skips config updates for normal file changes', async () => { - await messageProcessor.handleDidChangeNotification({ - textDocument: { - uri: `${pathToFileURL('.')}/foo.js`, - version: 1, - }, - contentChanges: [{ text: 'var something' }], - }); - expect(messageProcessor._parser).not.toHaveBeenCalled(); - }); - }); -}); - -describe('MessageProcessor with no config', () => { - let messageProcessor: MessageProcessor; - const mockRoot = join('/tmp', 'test'); - let loggerSpy: jest.SpyInstance; - - const mockProcessor = (query: string, config?: string) => { - const items = { - 'query.graphql': query, - 'node_modules/parse-json': mockfs.load('node_modules/parse-json'), - }; - if (config) { - items['graphql.config.js'] = config; - } - const files: Record> = { - [mockRoot]: mockfs.directory({ - items, - }), - // node_modules: mockfs.load('node_modules'), - }; - mockfs(files); - const logger = new NoopLogger(); - loggerSpy = jest.spyOn(logger, 'error'); - messageProcessor = new MessageProcessor({ - // @ts-ignore - connection: { - // @ts-ignore - get workspace() { - return { - async getConfiguration() { - return []; - }, - }; - }, - }, - logger, - graphqlFileExtensions: ['graphql'], - loadConfigOptions: { rootDir: mockRoot }, - }); - }; - - beforeEach(() => {}); - - afterEach(() => { - mockfs.restore(); - }); - it('fails to initialize with empty config file', async () => { - mockProcessor('query { foo }', ''); - await messageProcessor.handleInitializeRequest( - // @ts-ignore - { - rootPath: mockRoot, - }, - null, - mockRoot, - ); - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - text: 'query { foo }', - uri: `${mockRoot}/query.graphql`, - version: 1, - }, - }); - expect(messageProcessor._isInitialized).toEqual(false); - expect(messageProcessor._isGraphQLConfigMissing).toEqual(true); - expect(loggerSpy).toHaveBeenCalledTimes(1); - expect(loggerSpy).toHaveBeenCalledWith( - expect.stringMatching( - /GraphQL Config file is not available in the provided config directory/, - ), - ); - }); - it('fails to initialize with no config file present', async () => { - mockProcessor('query { foo }'); - await messageProcessor.handleInitializeRequest( - // @ts-ignore - { - rootPath: mockRoot, - }, - null, - mockRoot, - ); - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - text: 'query { foo }', - uri: `${mockRoot}/query.graphql`, - version: 1, - }, - }); - expect(messageProcessor._isInitialized).toEqual(false); - expect(messageProcessor._isGraphQLConfigMissing).toEqual(true); - expect(loggerSpy).toHaveBeenCalledTimes(1); - expect(loggerSpy).toHaveBeenCalledWith( - expect.stringMatching( - /GraphQL Config file is not available in the provided config directory/, - ), - ); - }); - it('initializes when presented with a valid config later', async () => { - mockProcessor('query { foo }'); - await messageProcessor.handleInitializeRequest( - // @ts-ignore - { - rootPath: mockRoot, - }, - null, - mockRoot, - ); - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - text: 'query { foo }', - uri: `${mockRoot}/query.graphql`, - version: 1, - }, - }); - expect(messageProcessor._isInitialized).toEqual(false); - expect(loggerSpy).toHaveBeenCalledTimes(1); - // todo: get mockfs working with in-test file changes - // mockfs.restore(); - // mockfs({ - // [mockRoot]: mockfs.directory({ - // mode: 0o755, - // items: { - // 'schema.graphql': - // 'type Query { foo: String }\nschema { query: Query }', - // 'graphql.config.js': mockfs.file({ - // content: 'module.exports = { schema: "schema.graphql" };', - // mode: 0o644, - // }), - // 'query.graphql': 'query { foo }', - // // 'node_modules/graphql-config/node_modules': mockfs.load( - // // 'node_modules/graphql-config/node_modules', - // // ), - // }, - // }), - // }); - // // console.log(readdirSync(`${mockRoot}`)); - // await messageProcessor.handleDidOpenOrSaveNotification({ - // textDocument: { - // text: 'module.exports = { schema: `schema.graphql` }', - // uri: `${mockRoot}/graphql.config.js`, - // version: 2, - // }, - // }); - - // expect(messageProcessor._isGraphQLConfigMissing).toEqual(false); - - // expect(loggerSpy).toHaveBeenCalledWith( - // expect.stringMatching( - // /GraphQL Config file is not available in the provided config directory/, - // ), - // ); - }); -}); diff --git a/packages/graphql-language-service-server/src/__tests__/MessageProcessor.spec.ts b/packages/graphql-language-service-server/src/__tests__/MessageProcessor.spec.ts new file mode 100644 index 00000000000..f5a9a091160 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/MessageProcessor.spec.ts @@ -0,0 +1,610 @@ +import mockfs from 'mock-fs'; +import { join } from 'node:path'; +import { MockFile, MockProject } from './__utils__/MockProject'; +// import { readFileSync } from 'node:fs'; +import { FileChangeType } from 'vscode-languageserver'; +import { serializeRange } from './__utils__/utils'; +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { URI } from 'vscode-uri'; +import { GraphQLSchema, introspectionFromSchema } from 'graphql'; +import fetchMock from 'fetch-mock'; + +jest.mock('@whatwg-node/fetch', () => { + const { AbortController } = require('node-abort-controller'); + + return { + fetch: require('fetch-mock').fetchHandler, + AbortController, + TextDecoder: global.TextDecoder, + }; +}); + +const mockSchema = (schema: GraphQLSchema) => { + const introspectionResult = { + data: introspectionFromSchema(schema, { + descriptions: true, + }), + }; + fetchMock.mock({ + matcher: '*', + response: { + headers: { + 'Content-Type': 'application/json', + }, + body: introspectionResult, + }, + }); +}; + +const defaultFiles = [ + ['query.graphql', 'query { bar ...B }'], + ['fragments.graphql', 'fragment B on Foo { bar }'], +] as MockFile[]; +const schemaFile: MockFile = [ + 'schema.graphql', + 'type Query { foo: Foo, test: Test }\n\ntype Foo { bar: String }\n\ntype Test { test: Foo }', +]; + +const fooTypePosition = { + start: { line: 2, character: 0 }, + end: { line: 2, character: 24 }, +}; + +const fooInlineTypePosition = { + start: { line: 5, character: 0 }, + end: { line: 5, character: 24 }, +}; + +const genSchemaPath = + '/tmp/graphql-language-service/test/projects/default/generated-schema.graphql'; + +// TODO: +// - reorganize into multiple files +// - potentially a high level abstraction and/or it.each() for a pathway across configs, file extensions, etc. +// this may be cumbersome with offset position assertions but possible +// if we can create consistency that doesn't limit variability +// - convert each it() into a nested describe() block (or a top level describe() in another file), and sprinkle in it() statements to replace comments +// - fix TODO comments where bugs were found that couldn't be resolved quickly (2-4hr time box) + +describe('MessageProcessor with no config', () => { + afterEach(() => { + mockfs.restore(); + fetchMock.restore(); + }); + it('fails to initialize with empty config file', async () => { + const project = new MockProject({ + files: [...defaultFiles, ['graphql.config.json', '']], + }); + await project.init(); + + expect(project.lsp._logger.info).toHaveBeenCalledTimes(1); + expect(project.lsp._logger.error).toHaveBeenCalledTimes(1); + expect(project.lsp._logger.error).toHaveBeenCalledWith( + expect.stringMatching( + /GraphQL Config file is not available in the provided config directory/, + ), + ); + expect(project.lsp._isInitialized).toEqual(false); + expect(project.lsp._isGraphQLConfigMissing).toEqual(true); + project.lsp.handleShutdownRequest(); + }); + it('fails to initialize with no config file present', async () => { + const project = new MockProject({ + files: [...defaultFiles], + }); + await project.init(); + + expect(project.lsp._logger.error).toHaveBeenCalledTimes(1); + expect(project.lsp._logger.error).toHaveBeenCalledWith( + expect.stringMatching( + /GraphQL Config file is not available in the provided config directory/, + ), + ); + expect(project.lsp._isInitialized).toEqual(false); + expect(project.lsp._isGraphQLConfigMissing).toEqual(true); + project.lsp.handleShutdownRequest(); + }); + it('initializes when presented with a valid config later', async () => { + const project = new MockProject({ + files: [...defaultFiles], + }); + await project.init(); + expect(project.lsp._isInitialized).toEqual(false); + expect(project.lsp._isGraphQLConfigMissing).toEqual(true); + expect(project.lsp._logger.error).toHaveBeenCalledTimes(1); + + project.changeFile( + 'graphql.config.json', + '{ "schema": "./schema.graphql" }', + ); + // TODO: this should work for on watched file changes as well! + await project.lsp.handleDidOpenOrSaveNotification({ + textDocument: { + uri: project.uri('graphql.config.json'), + }, + }); + expect(project.lsp._isInitialized).toEqual(true); + expect(project.lsp._isGraphQLConfigMissing).toEqual(false); + expect(project.lsp._graphQLCache).toBeDefined(); + project.lsp.handleShutdownRequest(); + }); +}); + +describe('MessageProcessor with config', () => { + afterEach(() => { + mockfs.restore(); + fetchMock.restore(); + }); + // beforeAll(async () => { + // app = await import('../../../graphiql/test/e2e-server'); + // }); + // afterAll(() => { + // app.server.close(); + // app.wsServer.close(); + // }); + it('caches files and schema with .graphql file config, and the schema updates with watched file changes', async () => { + const project = new MockProject({ + files: [ + schemaFile, + [ + 'graphql.config.json', + '{ "schema": "./schema.graphql", "documents": "./**.graphql" }', + ], + ...defaultFiles, + ], + }); + const results = await project.init('query.graphql'); + expect(results.diagnostics[0].message).toEqual( + 'Cannot query field "bar" on type "Query".', + ); + expect(results.diagnostics[1].message).toEqual( + 'Fragment "B" cannot be spread here as objects of type "Query" can never be of type "Foo".', + ); + const initSchemaDefRequest = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('schema.graphql') }, + position: { character: 19, line: 0 }, + }); + expect(initSchemaDefRequest.length).toEqual(1); + expect(initSchemaDefRequest[0].uri).toEqual(project.uri('schema.graphql')); + expect(serializeRange(initSchemaDefRequest[0].range)).toEqual( + fooTypePosition, + ); + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + expect(await project.lsp._graphQLCache.getSchema('default')).toBeDefined(); + // TODO: for some reason the cache result formats the graphql query?? + const docCache = project.lsp._textDocumentCache; + expect( + docCache.get(project.uri('query.graphql'))!.contents[0].query, + ).toContain('...B'); + const schemaDefinitions = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('fragments.graphql') }, + position: { character: 16, line: 0 }, + }); + expect(schemaDefinitions[0].uri).toEqual(project.uri('schema.graphql')); + + expect(serializeRange(schemaDefinitions[0].range)).toEqual(fooTypePosition); + + // query definition request of fragment name jumps to the fragment definition + const firstQueryDefRequest = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('query.graphql') }, + position: { character: 16, line: 0 }, + }); + + expect(firstQueryDefRequest[0].uri).toEqual( + project.uri('fragments.graphql'), + ); + expect(serializeRange(firstQueryDefRequest[0].range)).toEqual({ + start: { + line: 0, + character: 0, + }, + end: { + line: 2, + character: 1, + }, + }); + + // change the file to make the fragment invalid + project.changeFile( + 'schema.graphql', + // now Foo has a bad field, the fragment should be invalid + 'type Query { foo: Foo, test: Test }\n\n type Test { test: String }\n\n\n\n\ntype Foo { bad: Int }', + ); + await project.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { uri: project.uri('schema.graphql'), type: FileChangeType.Changed }, + ], + }); + const typeCache = + project.lsp._graphQLCache._typeDefinitionsCache.get('/tmp/test-default'); + expect(typeCache?.get('Test')?.definition.name.value).toEqual('Test'); + + // test in-file schema defs! important! + const schemaDefRequest = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('schema.graphql') }, + position: { character: 19, line: 0 }, + }); + + const fooLaterTypePosition = { + start: { line: 7, character: 0 }, + end: { line: 7, character: 21 }, + }; + expect(schemaDefRequest.length).toEqual(1); + expect(schemaDefRequest[0].uri).toEqual(project.uri('schema.graphql')); + expect(serializeRange(schemaDefRequest[0].range)).toEqual( + fooLaterTypePosition, + ); + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + // change the file to make the fragment invalid + project.changeFile( + 'schema.graphql', + // now Foo has a bad field, the fragment should be invalid + 'type Query { foo: Foo, test: Test }\n\n type Test { test: String }\n\n\n\n\n\ntype Foo { bad: Int }', + ); + // await project.lsp.handleWatchedFilesChangedNotification({ + // changes: [ + // { + // type: FileChangeType.Changed, + // uri: project.uri('schema.graphql'), + // }, + // ], + // }); + await project.lsp.handleDidChangeNotification({ + contentChanges: [ + { + type: FileChangeType.Changed, + text: 'type Query { foo: Foo, test: Test }\n\n type Test { test: String }\n\n\n\n\n\ntype Foo { bad: Int }', + }, + ], + textDocument: { uri: project.uri('schema.graphql'), version: 1 }, + }); + + const schemaDefRequest2 = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('schema.graphql') }, + position: { character: 19, line: 0 }, + }); + + const fooLaterTypePosition2 = { + start: { line: 8, character: 0 }, + end: { line: 8, character: 21 }, + }; + expect(schemaDefRequest2.length).toEqual(1); + expect(schemaDefRequest2[0].uri).toEqual(project.uri('schema.graphql')); + expect(serializeRange(schemaDefRequest2[0].range)).toEqual( + fooLaterTypePosition2, + ); + + // TODO: this fragment should now be invalid + const result = await project.lsp.handleDidOpenOrSaveNotification({ + textDocument: { uri: project.uri('fragments.graphql') }, + }); + expect(result.diagnostics[0].message).toEqual( + 'Cannot query field "bar" on type "Foo". Did you mean "bad"?', + ); + const generatedFile = existsSync(join(genSchemaPath)); + // this generated file should not exist because the schema is local! + expect(generatedFile).toEqual(false); + // simulating codegen + project.changeFile( + 'fragments.graphql', + 'fragment A on Foo { bad }\n\nfragment B on Test { test }', + ); + await project.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { uri: project.uri('fragments.graphql'), type: FileChangeType.Changed }, + ], + }); + + // TODO: this interface should maybe not be tested here but in unit tests + const fragCache = + project.lsp._graphQLCache._fragmentDefinitionsCache.get( + '/tmp/test-default', + ); + expect(fragCache?.get('A')?.definition.name.value).toEqual('A'); + expect(fragCache?.get('B')?.definition.name.value).toEqual('B'); + const queryFieldDefRequest = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('fragments.graphql') }, + position: { character: 22, line: 0 }, + }); + expect(queryFieldDefRequest[0].uri).toEqual(project.uri('schema.graphql')); + expect(serializeRange(queryFieldDefRequest[0].range)).toEqual({ + start: { + line: 8, + character: 11, + }, + end: { + line: 8, + character: 19, + }, + }); + + // on the second request, the position has changed + const secondQueryDefRequest = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('query.graphql') }, + position: { character: 16, line: 0 }, + }); + expect(secondQueryDefRequest[0].uri).toEqual( + project.uri('fragments.graphql'), + ); + expect(serializeRange(secondQueryDefRequest[0].range)).toEqual({ + start: { + line: 2, + character: 0, + }, + end: { + line: 2, + character: 27, + }, + }); + // definitions request for fragments jumps to a different place in schema.graphql now + const schemaDefinitionsAgain = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('fragments.graphql') }, + position: { character: 16, line: 0 }, + }); + expect(schemaDefinitionsAgain[0].uri).toEqual( + project.uri('schema.graphql'), + ); + + expect(serializeRange(schemaDefinitionsAgain[0].range)).toEqual( + fooLaterTypePosition2, + ); + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + project.lsp.handleShutdownRequest(); + }); + + it('caches files and schema with a URL config', async () => { + mockSchema(require('../../../graphiql/test/schema')); + + const project = new MockProject({ + files: [ + ['query.graphql', 'query { test { isTest, ...T } }'], + ['fragments.graphql', 'fragment T on Test {\n isTest \n}'], + [ + 'graphql.config.json', + '{ "schema": "http://localhost:3100/graphql", "documents": "./**" }', + ], + ], + }); + + const initParams = await project.init('query.graphql'); + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + + expect(initParams.diagnostics).toEqual([]); + + const changeParams = await project.lsp.handleDidChangeNotification({ + textDocument: { uri: project.uri('query.graphql'), version: 1 }, + contentChanges: [{ text: 'query { test { isTest, ...T or } }' }], + }); + expect(changeParams?.diagnostics[0].message).toEqual( + 'Cannot query field "or" on type "Test".', + ); + expect(await project.lsp._graphQLCache.getSchema('default')).toBeDefined(); + + // schema file is present and contains schema + const file = await readFile(join(genSchemaPath), { encoding: 'utf-8' }); + expect(file.split('\n').length).toBeGreaterThan(10); + + // hover works + const hover = await project.lsp.handleHoverRequest({ + position: { + character: 10, + line: 0, + }, + textDocument: { uri: project.uri('query.graphql') }, + }); + expect(hover.contents).toContain('`test` field from `Test` type.'); + + // ensure that fragment definitions work + const definitions = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('query.graphql') }, // console.log(project.uri('query.graphql')) + position: { character: 26, line: 0 }, + }); + expect(definitions[0].uri).toEqual(project.uri('fragments.graphql')); + expect(serializeRange(definitions[0].range)).toEqual({ + start: { + line: 0, + character: 0, + }, + end: { + line: 2, + character: 1, + }, + }); + + const typeDefinitions = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('fragments.graphql') }, + position: { character: 15, line: 0 }, + }); + + expect(typeDefinitions[0].uri).toEqual(URI.parse(genSchemaPath).toString()); + + expect(serializeRange(typeDefinitions[0].range)).toEqual({ + start: { + line: 11, + character: 0, + }, + end: { + line: 102, + character: 1, + }, + }); + + const schemaDefs = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: URI.parse(genSchemaPath).toString() }, + position: { character: 20, line: 18 }, + }); + expect(schemaDefs[0].uri).toEqual(URI.parse(genSchemaPath).toString()); + // note: if the graphiql test schema changes, + // this might break, please adjust if you see a failure here + expect(serializeRange(schemaDefs[0].range)).toEqual({ + start: { + line: 104, + character: 0, + }, + end: { + line: 112, + character: 1, + }, + }); + // lets remove the fragments file + await project.deleteFile('fragments.graphql'); + // and add a fragments.ts file, watched + await project.addFile( + 'fragments.ts', + '\n\n\nexport const fragment = gql`\n\n fragment T on Test { isTest } \n query { hasArgs(string: "") }\n`', + true, + ); + + await project.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { uri: project.uri('fragments.ts'), type: FileChangeType.Created }, + ], + }); + const defsForTs = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('query.graphql') }, + position: { character: 26, line: 0 }, + }); + // this one is really important + expect(defsForTs[0].uri).toEqual(project.uri('fragments.ts')); + expect(serializeRange(defsForTs[0].range)).toEqual({ + start: { + line: 5, + character: 2, + }, + end: { + line: 5, + character: 31, + }, + }); + const defsForArgs = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('fragments.ts') }, + position: { character: 19, line: 6 }, + }); + + expect(defsForArgs[0].uri).toEqual(URI.parse(genSchemaPath).toString()); + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + project.lsp.handleShutdownRequest(); + }); + + it('caches multiple projects with files and schema with a URL config and a local schema', async () => { + mockSchema(require('../../../graphiql/test/schema')); + + const project = new MockProject({ + files: [ + [ + 'a/fragments.ts', + '\n\n\nexport const fragment = gql`\n\n fragment TestFragment on Test { isTest }\n`', + ], + [ + 'a/query.ts', + '\n\n\nexport const query = graphql`query { test { isTest ...T } }`', + ], + + [ + 'b/query.ts', + 'import graphql from "graphql"\n\n\nconst a = graphql` query example { test() { isTest ...T } }`', + ], + [ + 'b/fragments.ts', + '\n\n\nexport const fragment = gql`\n\n fragment T on Test { isTest }\n`', + ], + [ + 'b/schema.ts', + `\n\nexport const schema = gql(\`\n${schemaFile[1]}\`)`, + ], + [ + 'package.json', + `{ "graphql": { "projects": { + "a": { "schema": "http://localhost:3100/graphql", "documents": "./a/**" }, + "b": { "schema": "./b/schema.ts", "documents": "./b/**" } } + } + }`, + ], + schemaFile, + ], + }); + + const initParams = await project.init('a/query.ts'); + expect(initParams.diagnostics[0].message).toEqual('Unknown fragment "T".'); + + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + expect(await project.lsp._graphQLCache.getSchema('a')).toBeDefined(); + const file = await readFile(join(genSchemaPath.replace('default', 'a')), { + encoding: 'utf-8', + }); + expect(file.split('\n').length).toBeGreaterThan(10); + // add a new typescript file with empty query to the b project + // and expect autocomplete to only show options for project b + await project.addFile( + 'b/empty.ts', + 'import gql from "graphql-tag"\ngql`query a { }`', + ); + const completion = await project.lsp.handleCompletionRequest({ + textDocument: { uri: project.uri('b/empty.ts') }, + position: { character: 13, line: 1 }, + }); + + expect(completion.items?.length).toEqual(5); + expect(completion.items.map(i => i.label)).toEqual([ + 'foo', + 'test', + '__typename', + '__schema', + '__type', + ]); + // this confirms that autocomplete respects cross-project boundaries for types. + // it performs a definition request for the foo field in Query + const schemaCompletion1 = await project.lsp.handleCompletionRequest({ + textDocument: { uri: project.uri('b/schema.ts') }, + position: { character: 21, line: 3 }, + }); + expect(schemaCompletion1.items.map(i => i.label)).toEqual(['Foo']); + // it performs a definition request for the Foo type in Test.test + const schemaDefinition = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('b/schema.ts') }, + position: { character: 21, line: 6 }, + }); + expect(serializeRange(schemaDefinition[0].range)).toEqual( + fooInlineTypePosition, + ); + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + // simulate a watched schema file change (codegen, etc) + project.changeFile( + 'b/schema.ts', + `\n\nexport const schema = gql(\`\n${ + schemaFile[1] + '\ntype Example1 { field: }' + }\`\n)`, + ); + await project.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { uri: project.uri('b/schema.ts'), type: FileChangeType.Changed }, + ], + }); + // TODO: repeat this with other changes to the schema file and use a + // didChange event to see if the schema updates properly as well + // await project.lsp.handleDidChangeNotification({ + // textDocument: { uri: project.uri('b/schema.graphql'), version: 1 }, + // contentChanges: [ + // { text: schemaFile[1] + '\ntype Example1 { field: }' }, + // ], + // }); + // console.log(project.fileCache.get('b/schema.graphql')); + const schemaCompletion = await project.lsp.handleCompletionRequest({ + textDocument: { uri: project.uri('b/schema.ts') }, + position: { character: 25, line: 8 }, + }); + // TODO: SDL completion still feels incomplete here... where is Int? + // where is self-referential Example1? + expect(schemaCompletion.items.map(i => i.label)).toEqual([ + 'Query', + 'Foo', + 'String', + 'Test', + 'Boolean', + ]); + + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + project.lsp.handleShutdownRequest(); + }); +}); diff --git a/packages/graphql-language-service-server/src/__tests__/MessageProcessor.test.ts b/packages/graphql-language-service-server/src/__tests__/MessageProcessor.test.ts new file mode 100644 index 00000000000..381d7755b87 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/MessageProcessor.test.ts @@ -0,0 +1,981 @@ +/** + * 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. + * + */ +import { SymbolKind } from 'vscode-languageserver'; +import { FileChangeType } from 'vscode-languageserver-protocol'; +import { Position, Range } from 'graphql-language-service'; + +import { + MessageProcessor, + processDiagnosticsMessage, +} from '../MessageProcessor'; +import { parseDocument } from '../parseDocument'; + +jest.mock('../Logger'); + +jest.setTimeout(20000); + +import { GraphQLCache } from '../GraphQLCache'; + +import { + ConfigInvalidError, + ConfigNotFoundError, + LoaderNoResultError, + ProjectNotFoundError, + loadConfig, +} from 'graphql-config'; + +import type { DefinitionQueryResult, Outline } from 'graphql-language-service'; + +import { NoopLogger } from '../Logger'; +import { pathToFileURL } from 'node:url'; +import mockfs from 'mock-fs'; +import { join } from 'node:path'; + +jest.mock('node:fs', () => ({ + ...jest.requireActual('fs'), + readFileSync: jest.fn(jest.requireActual('fs').readFileSync), +})); + +describe('MessageProcessor', () => { + const logger = new NoopLogger(); + const messageProcessor = new MessageProcessor({ + // @ts-ignore + connection: {}, + logger, + graphqlFileExtensions: ['graphql'], + loadConfigOptions: { rootDir: __dirname }, + config: null, + }); + + const queryPathUri = pathToFileURL(`${__dirname}/__queries__`); + const textDocumentTestString = ` + { + hero(episode: NEWHOPE){ + } + } + `; + let gqlConfig; + beforeEach(async () => { + gqlConfig = await loadConfig({ rootDir: __dirname, extensions: [] }); + + // loadConfig.mockRestore(); + messageProcessor._settings = { load: {} }; + messageProcessor._graphQLCache = new GraphQLCache({ + configDir: __dirname, + config: gqlConfig, + parser: parseDocument, + logger: new NoopLogger(), + }); + messageProcessor._languageService = { + // @ts-ignore + getAutocompleteSuggestions(query, position, uri) { + return [{ label: `${query} at ${uri}` }]; + }, + // @ts-ignore + getDiagnostics(_query, _uri) { + return []; + }, + async getHoverInformation(_query, position, _uri) { + return { + contents: '```graphql\nField: hero\n```', + range: new Range(position, position), + }; + }, + async getDocumentSymbols(_query: string, uri: string) { + return [ + { + name: 'item', + kind: SymbolKind.Field, + location: { + uri, + range: { + start: { line: 1, character: 2 }, + end: { line: 1, character: 4 }, + }, + }, + }, + ]; + }, + async getOutline(_query: string): Promise { + return { + outlineTrees: [ + { + representativeName: 'item', + kind: 'Field', + startPosition: new Position(1, 2), + endPosition: new Position(1, 4), + children: [], + }, + ], + }; + }, + async getDefinition( + _query, + position, + uri, + ): Promise { + return { + queryRange: [new Range(position, position)], + printedName: 'example', + definitions: [ + { + position, + path: uri, + }, + ], + }; + }, + }; + }); + + let getConfigurationReturnValue = {}; + // @ts-ignore + messageProcessor._connection = { + // @ts-ignore + get workspace() { + return { + async getConfiguration() { + return [getConfigurationReturnValue]; + }, + }; + }, + }; + + const initialDocument = { + textDocument: { + text: textDocumentTestString, + uri: `${queryPathUri}/test.graphql`, + version: 0, + }, + }; + + messageProcessor._isInitialized = true; + + it('initializes properly and opens a file', async () => { + const { capabilities } = await messageProcessor.handleInitializeRequest( + // @ts-ignore + { + rootPath: __dirname, + }, + null, + __dirname, + ); + expect(capabilities.definitionProvider).toEqual(true); + expect(capabilities.workspaceSymbolProvider).toEqual(true); + expect(capabilities.completionProvider.resolveProvider).toEqual(true); + expect(capabilities.textDocumentSync).toEqual(1); + }); + it('detects a config file', async () => { + const result = await messageProcessor._isGraphQLConfigFile( + 'graphql.config.js', + ); + expect(result).toEqual(true); + const falseResult = await messageProcessor._isGraphQLConfigFile( + 'graphql.js', + ); + expect(falseResult).toEqual(false); + + mockfs({ [`${__dirname}/package.json`]: '{"graphql": {}}' }); + const pkgResult = await messageProcessor._isGraphQLConfigFile( + `file://${__dirname}/package.json`, + ); + mockfs.restore(); + expect(pkgResult).toEqual(true); + + mockfs({ [`${__dirname}/package.json`]: '{ }' }); + const pkgFalseResult = await messageProcessor._isGraphQLConfigFile( + `file://${__dirname}/package.json`, + ); + mockfs.restore(); + expect(pkgFalseResult).toEqual(false); + }); + it('runs completion requests properly', async () => { + const uri = `${queryPathUri}/test2.graphql`; + const query = 'test'; + messageProcessor._textDocumentCache.set(uri, { + version: 0, + contents: [ + { + query, + range: new Range(new Position(0, 0), new Position(0, 0)), + }, + ], + }); + + const test = { + position: new Position(0, 0), + textDocument: { uri }, + }; + const result = await messageProcessor.handleCompletionRequest(test); + expect(result).toEqual({ + items: [{ label: `${query} at ${uri}` }], + isIncomplete: false, + }); + }); + it('runs completion requests properly with no file present', async () => { + const test = { + position: new Position(0, 0), + textDocument: { uri: `${queryPathUri}/test13.graphql` }, + }; + const result = await messageProcessor.handleCompletionRequest(test); + expect(result).toEqual({ + items: [], + isIncomplete: false, + }); + }); + it('runs completion requests properly when not initialized', async () => { + const test = { + position: new Position(0, 3), + textDocument: { uri: `${queryPathUri}/test2.graphql` }, + }; + messageProcessor._isInitialized = false; + const result = await messageProcessor.handleCompletionRequest(test); + expect(result).toEqual({ + items: [], + isIncomplete: false, + }); + }); + + it('runs document symbol requests', async () => { + messageProcessor._isInitialized = true; + const uri = `${queryPathUri}/test3.graphql`; + const validQuery = ` + { + hero(episode: EMPIRE){ + ...testFragment + } + } + `; + + const newDocument = { + textDocument: { + text: validQuery, + uri, + version: 0, + }, + }; + + messageProcessor._textDocumentCache.set(uri, { + version: 0, + contents: [ + { + query: validQuery, + range: new Range(new Position(0, 0), new Position(0, 0)), + }, + ], + }); + + const test = { + textDocument: newDocument.textDocument, + }; + + const result = await messageProcessor.handleDocumentSymbolRequest(test); + + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(1); + expect(result[0].name).toEqual('item'); + expect(result[0].kind).toEqual(SymbolKind.Field); + expect(result[0].location.range).toEqual({ + start: { line: 1, character: 2 }, + end: { line: 1, character: 4 }, + }); + }); + it('runs document symbol requests with no file present', async () => { + const test = { + textDocument: { + uri: `${queryPathUri}/test4.graphql`, + version: 0, + }, + }; + + const result = await messageProcessor.handleDocumentSymbolRequest(test); + expect(result).toEqual([]); + }); + it('runs document symbol requests when not initialized', async () => { + const test = { + textDocument: { + uri: `${queryPathUri}/test3.graphql`, + version: 0, + }, + }; + messageProcessor._isInitialized = false; + const result = await messageProcessor.handleDocumentSymbolRequest(test); + expect(result).toEqual([]); + messageProcessor._isInitialized = true; + const nextResult = await messageProcessor.handleDocumentSymbolRequest(test); + expect(nextResult[0].location.uri).toContain('test3.graphql'); + expect(nextResult[0].name).toEqual('item'); + expect(nextResult.length).toEqual(1); + }); + + it('properly changes the file cache with the didChange handler', async () => { + const uri = `${queryPathUri}/test.graphql`; + messageProcessor._textDocumentCache.set(uri, { + version: 1, + contents: [ + { + query: '', + range: new Range(new Position(0, 0), new Position(0, 0)), + }, + ], + }); + const textDocumentChangedString = ` + { + hero(episode: NEWHOPE){ + name + } + } + `; + + const result = await messageProcessor.handleDidChangeNotification({ + textDocument: { + // @ts-ignore + text: textDocumentTestString, + uri, + version: 1, + }, + contentChanges: [ + { text: textDocumentTestString }, + { text: textDocumentChangedString }, + ], + }); + // Query fixed, no more errors + expect(result.diagnostics.length).toEqual(0); + }); + + it('does not crash on null value returned in response to workspace configuration', async () => { + // for some reason this is needed? can't be a good thing... must have done something to cause a performance hit on + // loading config schema.. + jest.setTimeout(10000); + const previousConfigurationValue = getConfigurationReturnValue; + getConfigurationReturnValue = null; + const result = await messageProcessor.handleDidChangeConfiguration({}); + expect(result).toEqual({}); + getConfigurationReturnValue = previousConfigurationValue; + }); + + it('properly removes from the file cache with the didClose handler', async () => { + await messageProcessor.handleDidCloseNotification(initialDocument); + + const position = { line: 4, character: 5 }; + const params = { textDocument: initialDocument.textDocument, position }; + + // Should throw because file has been deleted from cache + try { + const result = await messageProcessor.handleCompletionRequest(params); + expect(result).toEqual(null); + } catch {} + }); + + // modified to work with jest.mock() of WatchmanClient + it('runs definition requests', async () => { + jest.setTimeout(10000); + const validQuery = ` + { + hero(episode: EMPIRE){ + ...testFragment + } + } + `; + + const newDocument = { + textDocument: { + text: validQuery, + uri: `${queryPathUri}/test3.graphql`, + version: 1, + }, + }; + messageProcessor._getCachedDocument = (_uri: string) => ({ + version: 1, + contents: [ + { + query: validQuery, + range: new Range(new Position(0, 0), new Position(20, 4)), + }, + ], + }); + + await messageProcessor.handleDidOpenOrSaveNotification(newDocument); + + const test = { + position: new Position(3, 15), + textDocument: newDocument.textDocument, + }; + + const result = await messageProcessor.handleDefinitionRequest(test); + await expect(result[0].uri).toEqual(`${queryPathUri}/test3.graphql`); + }); + + it('retrieves custom results from locateCommand', async () => { + jest.setTimeout(10000); + const validQuery = ` + { + hero(episode: EMPIRE){ + ...testFragment + } + } + `; + + const newDocument = { + textDocument: { + text: validQuery, + uri: `${queryPathUri}/test3.graphql`, + version: 1, + }, + }; + messageProcessor._getCachedDocument = (_uri: string) => ({ + version: 1, + contents: [ + { + query: validQuery, + range: new Range(new Position(0, 0), new Position(20, 4)), + }, + ], + }); + + await messageProcessor.handleDidOpenOrSaveNotification(newDocument); + + const test = { + position: new Position(3, 15), + textDocument: newDocument.textDocument, + }; + const result = await messageProcessor._languageService.getDefinition( + validQuery, + test.position, + test.textDocument.uri, + ); + const project = messageProcessor._graphQLCache.getProjectForFile( + test.textDocument.uri, + )!; + + const customResult = messageProcessor._getCustomLocateResult( + project, + { definitions: result, printedName: 'example' }, + () => 'hello', + ); + expect(customResult.uri).toEqual('hello'); + + const customResult2 = messageProcessor._getCustomLocateResult( + project, + { definitions: result, printedName: 'example' }, + () => 'hello:2:4', + ); + expect(customResult2.uri).toEqual('hello'); + expect(customResult2.range.start.line).toEqual(2); + expect(customResult2.range.start.character).toEqual(0); + expect(customResult2.range.end.line).toEqual(4); + + const customResult3 = messageProcessor._getCustomLocateResult( + project, + { definitions: result, printedName: 'example' }, + () => ({ + uri: 'hello1', + range: { + start: { character: 2, line: 2 }, + end: { character: 4, line: 4 }, + }, + }), + ); + expect(customResult3.uri).toEqual('hello1'); + expect(customResult3.range.start.line).toEqual(2); + expect(customResult3.range.start.character).toEqual(2); + expect(customResult3.range.end.line).toEqual(4); + expect(customResult3.range.end.character).toEqual(4); + const oldGetProject = messageProcessor._graphQLCache.getProjectForFile; + + messageProcessor._graphQLCache.getProjectForFile = jest.fn(() => ({ + schema: project.schema, + documents: project.documents, + dirpath: project.dirpath, + extensions: { + languageService: { locateCommand: () => 'foo:3:4' }, + }, + })); + const result2 = await messageProcessor.handleDefinitionRequest(test); + expect(result2[0].range.start.line).toBe(3); + expect(result2[0].range.end.line).toBe(4); + expect(result2[0].range.end.character).toBe(0); + messageProcessor._graphQLCache.getProjectForFile = oldGetProject; + }); + it('runs hover requests', async () => { + const validQuery = ` + { + hero(episode: EMPIRE){ + ...testFragment + } + } + `; + + const newDocument = { + textDocument: { + text: validQuery, + uri: `${queryPathUri}/test4.graphql`, + version: 1, + }, + }; + messageProcessor._getCachedDocument = (_uri: string) => ({ + version: 1, + contents: [ + { + query: validQuery, + range: new Range(new Position(0, 0), new Position(20, 4)), + }, + ], + }); + + await messageProcessor.handleDidOpenOrSaveNotification(newDocument); + + const test = { + position: new Position(3, 15), + textDocument: newDocument.textDocument, + }; + + const result = await messageProcessor.handleHoverRequest(test); + expect(JSON.stringify(result.contents)).toEqual( + JSON.stringify({ + contents: '```graphql\nField: hero\n```', + range: new Range(new Position(3, 15), new Position(3, 15)), + }), + ); + }); + it('runs hover request with no file present', async () => { + const test = { + position: new Position(3, 15), + textDocument: { + uri: `${queryPathUri}/test5.graphql`, + version: 1, + }, + }; + messageProcessor._getCachedDocument = (_uri: string) => null; + + const result = await messageProcessor.handleHoverRequest(test); + expect(result).toEqual({ contents: [] }); + }); + it('handles provided config', async () => { + const msgProcessor = new MessageProcessor({ + // @ts-ignore + connection: { + workspace: { + getConfiguration() { + return {}; + }, + }, + }, + logger, + graphqlFileExtensions: ['graphql'], + loadConfigOptions: { rootDir: __dirname }, + config: gqlConfig, + }); + expect(msgProcessor._providedConfig).toBeTruthy(); + await msgProcessor.handleInitializeRequest( + // @ts-ignore + { + rootPath: __dirname, + }, + null, + __dirname, + ); + await msgProcessor.handleDidChangeConfiguration({ + settings: {}, + }); + expect(msgProcessor._graphQLCache).toBeTruthy(); + }); + + it('runs workspace symbol requests', async () => { + const msgProcessor = new MessageProcessor({ + // @ts-ignore + connection: {}, + logger, + graphqlFileExtensions: ['graphql'], + loadConfigOptions: { rootDir: __dirname }, + }); + await msgProcessor.handleInitializeRequest( + // @ts-ignore + { + rootPath: __dirname, + }, + null, + __dirname, + ); + const uri = `${queryPathUri}/test6.graphql`; + const docUri = `${queryPathUri}/test7.graphql`; + const validQuery = ` + { + hero(episode: EMPIRE){ + ...testFragment + } + } + `; + const validDocument = ` + fragment testFragment on Character { + name + }`; + msgProcessor._graphQLCache = new GraphQLCache({ + configDir: __dirname, + config: await loadConfig({ rootDir: __dirname }), + parser: parseDocument, + logger: new NoopLogger(), + }); + msgProcessor._languageService = { + getDocumentSymbols: async () => [ + { + name: 'testFragment', + kind: SymbolKind.Field, + location: { + uri, + range: { + start: { line: 1, character: 2 }, + end: { line: 1, character: 4 }, + }, + }, + }, + ], + }; + msgProcessor._isInitialized = true; + msgProcessor._textDocumentCache.set(uri, { + version: 0, + contents: [ + { + query: validQuery, + range: new Range(new Position(0, 0), new Position(6, 0)), + }, + ], + }); + + await msgProcessor._graphQLCache.updateFragmentDefinition( + __dirname, + docUri, + [ + { + query: validDocument, + range: new Range(new Position(0, 0), new Position(4, 0)), + }, + ], + ); + + const test = { + query: 'testFragment', + }; + + const result = await msgProcessor.handleWorkspaceSymbolRequest(test); + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(1); + expect(result[0].name).toEqual('testFragment'); + expect(result[0].kind).toEqual(SymbolKind.Field); + expect(result[0].location.range).toEqual({ + start: { line: 1, character: 2 }, + end: { line: 1, character: 4 }, + }); + }); + + describe('_loadConfigOrSkip', () => { + const mockReadFileSync: jest.Mock = + jest.requireMock('node:fs').readFileSync; + + beforeEach(() => { + mockReadFileSync.mockReturnValue(''); + messageProcessor._initializeGraphQLCaches = jest.fn(); + }); + + it('loads config if not initialized', async () => { + messageProcessor._isInitialized = false; + + const result = await messageProcessor._loadConfigOrSkip( + `${pathToFileURL('.')}/graphql.config.js`, + ); + expect(messageProcessor._initializeGraphQLCaches).toHaveBeenCalledTimes( + 1, + ); + // we want to return true here to skip further processing, because it's just a config file change + expect(result).toEqual(true); + }); + + it('loads config if a file change occurs and the server is not initialized', async () => { + messageProcessor._isInitialized = false; + + const result = await messageProcessor._loadConfigOrSkip( + `${pathToFileURL('.')}/file.ts`, + ); + expect(messageProcessor._initializeGraphQLCaches).toHaveBeenCalledTimes( + 1, + ); + // here we have a non-config file, so we don't want to skip, because we need to run diagnostics etc + expect(result).toEqual(false); + }); + it('config file change updates server config even if the server is already initialized', async () => { + messageProcessor._isInitialized = true; + const result = await messageProcessor._loadConfigOrSkip( + `${pathToFileURL('.')}/graphql.config.ts`, + ); + expect(messageProcessor._initializeGraphQLCaches).toHaveBeenCalledTimes( + 1, + ); + expect(result).toEqual(true); + }); + it('skips if the server is already initialized', async () => { + messageProcessor._isInitialized = true; + const result = await messageProcessor._loadConfigOrSkip( + `${pathToFileURL('.')}/myFile.ts`, + ); + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(result).toEqual(false); + }); + }); + + describe('handleDidOpenOrSaveNotification', () => { + const mockReadFileSync: jest.Mock = + jest.requireMock('node:fs').readFileSync; + + beforeEach(() => { + mockReadFileSync.mockReturnValue(''); + messageProcessor._initializeGraphQLCaches = jest.fn(); + messageProcessor._loadConfigOrSkip = jest.fn(); + }); + it('updates config for standard config filename changes', async () => { + await messageProcessor.handleDidOpenOrSaveNotification({ + textDocument: { + uri: `${pathToFileURL('.')}/.graphql.config.js`, + languageId: 'js', + version: 0, + text: '', + }, + }); + expect(messageProcessor._loadConfigOrSkip).toHaveBeenCalled(); + }); + + it('updates config for custom config filename changes', async () => { + const customConfigName = 'custom-config-name.yml'; + messageProcessor._settings = { load: { fileName: customConfigName } }; + + await messageProcessor.handleDidOpenOrSaveNotification({ + textDocument: { + uri: `${pathToFileURL('.')}/${customConfigName}`, + languageId: 'js', + version: 0, + text: '', + }, + }); + + expect(messageProcessor._loadConfigOrSkip).toHaveBeenCalledWith( + expect.stringContaining(customConfigName), + ); + }); + + it('handles config requests with no config', async () => { + messageProcessor._settings = {}; + + await messageProcessor.handleDidChangeConfiguration({ + settings: [], + }); + + expect(messageProcessor._initializeGraphQLCaches).toHaveBeenCalled(); + + await messageProcessor.handleDidOpenOrSaveNotification({ + textDocument: { + uri: `${pathToFileURL('.')}/.graphql.config.js`, + languageId: 'js', + version: 0, + text: '', + }, + }); + + expect(messageProcessor._initializeGraphQLCaches).toHaveBeenCalled(); + }); + }); + + describe('_handleConfigErrors', () => { + it('handles missing config errors', async () => { + messageProcessor._handleConfigError({ + err: new ConfigNotFoundError('test missing-config'), + uri: 'test', + }); + + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('test missing-config'), + ); + }); + it('handles missing project errors', async () => { + messageProcessor._handleConfigError({ + err: new ProjectNotFoundError('test missing-project'), + uri: 'test', + }); + + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Project not found for this file'), + ); + }); + it('handles invalid config errors', async () => { + messageProcessor._handleConfigError({ + err: new ConfigInvalidError('test invalid error'), + uri: 'test', + }); + + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid configuration'), + ); + }); + it('handles empty loader result errors', async () => { + messageProcessor._handleConfigError({ + err: new LoaderNoResultError('test loader-error'), + uri: 'test', + }); + + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('test loader-error'), + ); + }); + it('handles generic errors', async () => { + messageProcessor._handleConfigError({ + err: new Error('test loader-error'), + uri: 'test', + }); + + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('test loader-error'), + ); + }); + }); + describe('handleWatchedFilesChangedNotification', () => { + const mockReadFileSync: jest.Mock = + jest.requireMock('node:fs').readFileSync; + + beforeEach(() => { + mockReadFileSync.mockReturnValue(' query { id }'); + messageProcessor._initializeGraphQLCaches = jest.fn(); + messageProcessor._updateFragmentDefinition = jest.fn(); + messageProcessor._isGraphQLConfigMissing = false; + messageProcessor._isInitialized = true; + }); + + it('skips config updates for normal file changes', async () => { + await messageProcessor.handleWatchedFilesChangedNotification({ + changes: [ + { + uri: `${pathToFileURL( + join(__dirname, '__queries__'), + )}/test.graphql`, + type: FileChangeType.Changed, + }, + ], + }); + + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(messageProcessor._updateFragmentDefinition).toHaveBeenCalled(); + }); + }); + + describe('handleWatchedFilesChangedNotification without graphql config', () => { + const mockReadFileSync: jest.Mock = + jest.requireMock('node:fs').readFileSync; + + beforeEach(() => { + mockReadFileSync.mockReturnValue(''); + messageProcessor._isGraphQLConfigMissing = true; + messageProcessor._parser = jest.fn(); + }); + + it('skips config updates for normal file changes', async () => { + await messageProcessor.handleWatchedFilesChangedNotification({ + changes: [ + { + uri: `${pathToFileURL('.')}/foo.js`, + type: FileChangeType.Changed, + }, + ], + }); + expect(messageProcessor._parser).not.toHaveBeenCalled(); + }); + }); + + describe('handleDidChangedNotification without graphql config', () => { + const mockReadFileSync: jest.Mock = + jest.requireMock('node:fs').readFileSync; + + beforeEach(() => { + mockReadFileSync.mockReturnValue(''); + messageProcessor._isGraphQLConfigMissing = true; + messageProcessor._parser = jest.fn(); + }); + + it('skips config updates for normal file changes', async () => { + await messageProcessor.handleDidChangeNotification({ + textDocument: { + uri: `${pathToFileURL('.')}/foo.js`, + version: 1, + }, + contentChanges: [{ text: 'var something' }], + }); + expect(messageProcessor._parser).not.toHaveBeenCalled(); + }); + }); +}); + +describe('processDiagnosticsMessage', () => { + it('processes diagnostics messages', () => { + const query = 'query { foo }'; + const inputRange = new Range(new Position(1, 1), new Position(1, 1)); + + const diagnostics = processDiagnosticsMessage( + [ + { + severity: 1, + message: 'test', + source: 'GraphQL: Validation', + range: inputRange, + }, + ], + query, + inputRange, + ); + + expect(JSON.stringify(diagnostics)).toEqual( + JSON.stringify([ + { + severity: 1, + message: 'test', + source: 'GraphQL: Validation', + range: new Range(new Position(2, 1), new Position(2, 1)), + }, + ]), + ); + }); + it('processes diagnostics messages with null range', () => { + const query = 'query { foo }'; + const inputRange = new Range(new Position(1, 1), new Position(1, 1)); + + const diagnostics = processDiagnosticsMessage( + [ + { + severity: 1, + message: 'test', + source: 'GraphQL: Validation', + range: inputRange, + }, + ], + query, + null, + ); + + expect(JSON.stringify(diagnostics)).toEqual( + JSON.stringify([ + { + severity: 1, + message: 'test', + source: 'GraphQL: Validation', + range: inputRange, + }, + ]), + ); + }); +}); diff --git a/packages/graphql-language-service-server/src/__tests__/__utils__/MockProject.ts b/packages/graphql-language-service-server/src/__tests__/__utils__/MockProject.ts new file mode 100644 index 00000000000..f0cd000e329 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/__utils__/MockProject.ts @@ -0,0 +1,197 @@ +import mockfs from 'mock-fs'; +import { MessageProcessor } from '../../MessageProcessor'; +import { Logger as VSCodeLogger } from 'vscode-jsonrpc'; +import { URI } from 'vscode-uri'; +import { FileChangeType } from 'vscode-languageserver'; +import { FileChangeTypeKind } from 'graphql-language-service'; + +export type MockFile = [filename: string, text: string]; + +export class MockLogger implements VSCodeLogger { + error = jest.fn(); + warn = jest.fn(); + info = jest.fn(); + log = jest.fn(); +} + +// when using mockfs with cosmic-config, a dynamic inline +// require of parse-json creates the necessity for loading in the actual +// modules to the mocked filesystem +const modules = [ + 'parse-json', + 'error-ex', + 'is-arrayish', + 'json-parse-even-better-errors', + 'lines-and-columns', + '@babel/code-frame', + '@babel/highlight', + // these i think are just required by jest when you console log from a test + 'jest-message-util', + 'stack-utils', + 'pretty-format', + 'ansi-regex', + 'js-tokens', + 'escape-string-regexp', + 'jest-worker', + 'jiti', + 'cosmiconfig', + 'minimatch', + 'tslib', +]; +const defaultMocks = modules.reduce((acc, module) => { + acc[`node_modules/${module}`] = mockfs.load(`node_modules/${module}`); + return acc; +}, {}); + +type File = [filename: string, text: string]; +type Files = File[]; + +export class MockProject { + private root: string; + private fileCache: Map; + private messageProcessor: MessageProcessor; + constructor({ + files = [], + root = '/tmp/test', + settings, + }: { + files: Files; + root?: string; + settings?: [name: string, vale: any][]; + }) { + this.root = root; + this.fileCache = new Map(files); + + this.mockFiles(); + this.messageProcessor = new MessageProcessor({ + connection: { + get workspace() { + return { + async getConfiguration() { + return settings; + }, + }; + }, + }, + logger: new MockLogger(), + loadConfigOptions: { + rootDir: root, + }, + }); + } + + public async init(filename?: string, fileText?: string) { + await this.lsp.handleInitializeRequest({ + rootPath: this.root, + rootUri: this.root, + capabilities: {}, + processId: 200, + workspaceFolders: null, + }); + return this.lsp.handleDidOpenOrSaveNotification({ + textDocument: { + uri: this.uri(filename || 'query.graphql'), + version: 1, + text: + this.fileCache.get('query.graphql') || + (filename && this.fileCache.get(filename)) || + fileText, + }, + }); + } + private mockFiles() { + const mockFiles = { ...defaultMocks }; + Array.from(this.fileCache).map(([filename, text]) => { + mockFiles[this.filePath(filename)] = text; + }); + mockfs(mockFiles); + } + public filePath(filename: string) { + return `${this.root}/${filename}`; + } + public uri(filename: string) { + return URI.file(this.filePath(filename)).toString(); + } + changeFile(filename: string, text: string) { + this.fileCache.set(filename, text); + this.mockFiles(); + } + async addFile(filename: string, text: string, watched = false) { + this.fileCache.set(filename, text); + this.mockFiles(); + if (watched) { + await this.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { + uri: this.uri(filename), + type: FileChangeTypeKind.Created, + }, + ], + }); + } + await this.lsp.handleDidChangeNotification({ + contentChanges: [ + { + type: FileChangeTypeKind.Created, + text, + }, + ], + textDocument: { + uri: this.uri(filename), + version: 2, + }, + }); + } + async changeWatchedFile(filename: string, text: string) { + this.changeFile(filename, text); + await this.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { + uri: this.uri(filename), + type: FileChangeType.Changed, + }, + ], + }); + } + async saveOpenFile(filename: string, text: string) { + this.changeFile(filename, text); + await this.lsp.handleDidOpenOrSaveNotification({ + textDocument: { + uri: this.uri(filename), + version: 2, + text, + }, + }); + } + async addWatchedFile(filename: string, text: string) { + this.changeFile(filename, text); + await this.lsp.handleDidChangeNotification({ + contentChanges: [ + { + type: FileChangeTypeKind.Created, + text, + }, + ], + textDocument: { + uri: this.uri(filename), + version: 2, + }, + }); + } + async deleteFile(filename: string) { + mockfs.restore(); + this.fileCache.delete(filename); + this.mockFiles(); + await this.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { + type: FileChangeType.Deleted, + uri: this.uri(filename), + }, + ], + }); + } + get lsp() { + return this.messageProcessor; + } +} diff --git a/packages/graphql-language-service-server/src/__tests__/__utils__/runServer.js b/packages/graphql-language-service-server/src/__tests__/__utils__/runServer.js new file mode 100644 index 00000000000..0e328a55450 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/__utils__/runServer.js @@ -0,0 +1 @@ +exports.default = require('../../../../graphiql/test/e2e-server.js'); diff --git a/packages/graphql-language-service-server/src/__tests__/__utils__/utils.ts b/packages/graphql-language-service-server/src/__tests__/__utils__/utils.ts new file mode 100644 index 00000000000..4ad1eff2c26 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/__utils__/utils.ts @@ -0,0 +1,4 @@ +import { Range } from 'vscode-languageserver'; + +export const serializeRange = (range: Range) => + JSON.parse(JSON.stringify(range)); diff --git a/packages/graphql-language-service-server/src/__tests__/findGraphQLTags-test.ts b/packages/graphql-language-service-server/src/__tests__/findGraphQLTags.test.ts similarity index 89% rename from packages/graphql-language-service-server/src/__tests__/findGraphQLTags-test.ts rename to packages/graphql-language-service-server/src/__tests__/findGraphQLTags.test.ts index 6bb5c1062bf..bbc1872dfac 100644 --- a/packages/graphql-language-service-server/src/__tests__/findGraphQLTags-test.ts +++ b/packages/graphql-language-service-server/src/__tests__/findGraphQLTags.test.ts @@ -83,6 +83,36 @@ query Test { ...FragmentsComment } } + + `); + }); + + it('finds queries in call expressions with with newlines preceding the template', async () => { + const text = ` + import {gql} from 'react-apollo'; + import type {B} from 'B'; + import A from './A'; + + const QUERY = gql( + \` + query Test { + test { + value + ...FragmentsComment + } + } + \`); + + export function Example(arg: string) {}`; + + const contents = findGraphQLTags(text, '.ts'); + expect(contents[0].template).toEqual(` + query Test { + test { + value + ...FragmentsComment + } + } `); }); @@ -112,6 +142,7 @@ query Test { ...FragmentsComment } } + `); }); @@ -122,9 +153,7 @@ import {B} from 'B'; import A from './A'; -const QUERY: string = -/* GraphQL */ -\` +const QUERY: string = /* GraphQL */ \` query Test { test { value @@ -144,6 +173,7 @@ query Test { ...FragmentsComment } } + `); }); @@ -529,4 +559,36 @@ export function Example(arg: string) {}`; const contents = findGraphQLTags(text, '.svelte'); expect(contents.length).toEqual(1); }); + it('handles full astro example', () => { + const text = ` + --- + const gql = String.raw; + const response = await fetch("https://swapi-graphql.netlify.app/.netlify/functions/index", + { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ + query: gql\` + query getFilm ($id:ID!) { + film(id: $id) { + title + releaseDate + } + } + \`, + variables: { + id: "XM6MQ==", + }, + }), + }); + + const json = await response.json(); + const { film } = json.data; + --- +

Fetching information about Star Wars: A New Hope

+

Title: {film.title}

+

Year: {film.releaseDate}

`; + const contents = findGraphQLTags(text, '.astro'); + expect(contents.length).toEqual(1); + }); }); diff --git a/packages/graphql-language-service-server/src/__tests__/parseDocument-test.ts b/packages/graphql-language-service-server/src/__tests__/parseDocument.test.ts similarity index 90% rename from packages/graphql-language-service-server/src/__tests__/parseDocument-test.ts rename to packages/graphql-language-service-server/src/__tests__/parseDocument.test.ts index 0020f76d47e..e62ac3473a1 100644 --- a/packages/graphql-language-service-server/src/__tests__/parseDocument-test.ts +++ b/packages/graphql-language-service-server/src/__tests__/parseDocument.test.ts @@ -1,7 +1,7 @@ import { parseDocument } from '../parseDocument'; describe('parseDocument', () => { - it('parseDocument finds queries in tagged templates', async () => { + it('parseDocument finds queries in tagged templates', () => { const text = ` // @flow import {gql} from 'react-apollo'; @@ -32,7 +32,7 @@ describe('parseDocument', () => { `); }); - it('parseDocument finds queries in tagged templates in leaf', async () => { + it('parseDocument finds queries in tagged templates in leaf', () => { const text = ` import {gql} from 'react-apollo'; import type {B} from 'B'; @@ -52,13 +52,13 @@ describe('parseDocument', () => { expect(contents[0].query).toEqual(` query Test { test { - + __typename } } `); }); - it('parseDocument finds queries in tagged templates using typescript', async () => { + it('parseDocument finds queries in tagged templates using typescript', () => { const text = ` import {gql} from 'react-apollo'; import {B} from 'B'; @@ -88,7 +88,7 @@ describe('parseDocument', () => { `); }); - it('parseDocument finds queries in tagged templates using tsx', async () => { + it('parseDocument finds queries in tagged templates using tsx', () => { const text = ` import {gql} from 'react-apollo'; import {B} from 'B'; @@ -120,7 +120,7 @@ describe('parseDocument', () => { `); }); - it('parseDocument finds queries in multi-expression tagged templates using tsx', async () => { + it('parseDocument finds queries in multi-expression tagged templates using tsx', () => { const text = ` import {gql} from 'react-apollo'; import {B} from 'B'; @@ -153,7 +153,7 @@ describe('parseDocument', () => { }`); }); // TODO: why an extra line here? - it('parseDocument finds queries in multi-expression tagged template with declarations with using tsx', async () => { + it('parseDocument finds queries in multi-expression tagged template with declarations with using tsx', () => { const text = ` import {gql} from 'react-apollo'; import {B} from 'B'; @@ -186,7 +186,7 @@ describe('parseDocument', () => { }`); }); - it('parseDocument finds queries in multi-expression template strings using tsx', async () => { + it('parseDocument finds queries in multi-expression template strings using tsx', () => { const text = ` import {gql} from 'react-apollo'; import {B} from 'B'; @@ -218,10 +218,11 @@ describe('parseDocument', () => { ...FragmentsComment } } + `); }); - it('parseDocument finds queries in call expressions with template literals', async () => { + it('parseDocument finds queries in call expressions with template literals', () => { const text = ` // @flow import {gql} from 'react-apollo'; @@ -248,10 +249,11 @@ describe('parseDocument', () => { ...FragmentsComment } } + `); }); - it('parseDocument finds queries in #graphql-annotated templates', async () => { + it('parseDocument finds queries in #graphql-annotated templates', () => { const text = ` import {gql} from 'react-apollo'; import {B} from 'B'; @@ -277,39 +279,42 @@ describe('parseDocument', () => { ...FragmentsComment } } + `); }); - it('parseDocument finds queries in /*GraphQL*/-annotated templates', async () => { + it('parseDocument finds queries in /*GraphQL*/-annotated templates', () => { const text = ` import {gql} from 'react-apollo'; import {B} from 'B'; import A from './A'; const QUERY: string = /* GraphQL */ \` - query Test { - test { - value - ...FragmentsComment + query Test { + test { + value + ...FragmentsComment + } } - } \${A.fragments.test} \` export function Example(arg: string) {}`; const contents = parseDocument(text, 'test.ts'); - expect(contents[0].query).toEqual(` - query Test { - test { - value - ...FragmentsComment + // please let me keep this whitespace prettier! + expect(contents[0].query).toEqual(/* GraphQL */ ` + query Test { + test { + value + ...FragmentsComment + } } - } + `); }); - it('parseDocument ignores non gql tagged templates', async () => { + it('parseDocument ignores non gql tagged templates', () => { const text = ` // @flow import randomThing from 'package'; @@ -332,7 +337,7 @@ describe('parseDocument', () => { expect(contents.length).toEqual(0); }); - it('parseDocument ignores non gql call expressions with template literals', async () => { + it('parseDocument ignores non gql call expressions with template literals', () => { const text = ` // @flow import randomthing from 'package'; @@ -355,7 +360,7 @@ describe('parseDocument', () => { expect(contents.length).toEqual(0); }); - it('an unparsable JS/TS file does not throw and bring down the server', async () => { + it('an unparsable JS/TS file does not throw and bring down the server', () => { const text = ` // @flow import type randomThing fro 'package'; @@ -375,14 +380,14 @@ describe('parseDocument', () => { expect(contents.length).toEqual(0); }); - it('an empty file is ignored', async () => { + it('an empty file is ignored', () => { const text = ''; const contents = parseDocument(text, 'test.js'); expect(contents.length).toEqual(0); }); - it('a whitespace only file with empty asts is ignored', async () => { + it('a whitespace only file with empty asts is ignored', () => { const text = ` `; @@ -391,7 +396,7 @@ describe('parseDocument', () => { expect(contents.length).toEqual(0); }); - it('an ignored file is ignored', async () => { + it('an ignored file is ignored', () => { const text = ` something `; diff --git a/packages/graphql-language-service-server/src/__tests__/startServer.spec.ts b/packages/graphql-language-service-server/src/__tests__/startServer.spec.ts new file mode 100644 index 00000000000..6e7c700a260 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/startServer.spec.ts @@ -0,0 +1,41 @@ +import startServer from '../startServer'; + +describe('startServer', () => { + let c; + afterEach(async () => { + if (c) { + try { + await c.sendNotification('exit'); + } catch {} + } + }); + it('should start the server', async () => { + c = await startServer(); + // if the server starts, we're good + expect(true).toBe(true); + }); + // TODO: this one fails to exit the astro workers, perhaps a bug? + // eslint-disable-next-line jest/no-disabled-tests + it.skip('should start the server with stream', async () => { + c = await startServer({ + method: 'stream', + }); + // if the server starts, we're good + expect(true).toBe(true); + }); + it('should start the server with ipc', async () => { + c = await startServer({ + method: 'node', + }); + // if the server starts, we're good + expect(true).toBe(true); + }); + it('should start the server with websockets', async () => { + c = await startServer({ + method: 'socket', + port: 4000, + }); + // if the server starts, we're good + expect(true).toBe(true); + }); +}); diff --git a/packages/graphql-language-service-server/src/__tests__/startServer.test.ts b/packages/graphql-language-service-server/src/__tests__/startServer.test.ts new file mode 100644 index 00000000000..dea290df0fa --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/startServer.test.ts @@ -0,0 +1,109 @@ +import { IPCMessageReader, IPCMessageWriter } from 'vscode-jsonrpc/node'; +import { addHandlers, buildOptions, initializeHandlers } from '../startServer'; + +describe('buildOptions', () => { + it('should build options', () => { + const options = buildOptions({}); + expect(options).toEqual({ + loadConfigOptions: { + extensions: [], + rootDir: process.cwd(), + }, + }); + }); + it('should build options with loadConfigOptions', () => { + const options = buildOptions({ loadConfigOptions: { rootDir: '/root' } }); + expect(options).toEqual({ + loadConfigOptions: { + rootDir: '/root', + }, + }); + }); + it('should build options with loadConfigOptions without rootDir', () => { + const options = buildOptions({ loadConfigOptions: { extensions: [] } }); + expect(options).toEqual({ + loadConfigOptions: { + rootDir: process.cwd(), + extensions: [], + }, + }); + }); + it('should build options with just extensions', () => { + const options = buildOptions({ extensions: [] }); + expect(options).toEqual({ + extensions: [], + loadConfigOptions: { + rootDir: process.cwd(), + extensions: [], + }, + }); + }); +}); + +describe('initializeHandlers', () => { + beforeEach(() => { + jest.resetModules(); + }); + it('should initialize handlers', async () => { + const reader = new IPCMessageReader(process); + const writer = new IPCMessageWriter(process); + const handlers = await initializeHandlers({ + reader, + writer, + options: { + loadConfigOptions: { rootDir: '/root' }, + }, + }); + expect(handlers).toBeDefined(); + }); +}); + +describe('addHandlers', () => { + it('should add handlers', async () => { + const connection = { + onInitialize: jest.fn(), + onInitialized: jest.fn(), + onShutdown: jest.fn(), + onExit: jest.fn(), + onNotification: jest.fn(), + onRequest: jest.fn(), + sendNotification: jest.fn(), + sendRequest: jest.fn(), + console: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + log: jest.fn(), + }, + }; + + await addHandlers({ + connection, + options: { loadConfigOptions: { rootDir: '/root' } }, + }); + expect( + connection.onNotification.mock.calls.map(c => c[0].method ?? c[0]), + ).toEqual([ + 'textDocument/didOpen', + 'textDocument/didSave', + 'textDocument/didChange', + 'textDocument/didClose', + 'exit', + '$/cancelRequest', + 'workspace/didChangeWatchedFiles', + 'workspace/didChangeConfiguration', + ]); + expect( + connection.onRequest.mock.calls.map(c => c[0].method ?? c[0]), + ).toEqual([ + 'shutdown', + 'initialize', + 'textDocument/completion', + 'completionItem/resolve', + 'textDocument/definition', + 'textDocument/hover', + 'textDocument/documentSymbol', + 'workspace/symbol', + ]); + }); +}); diff --git a/packages/graphql-language-service-server/src/common.ts b/packages/graphql-language-service-server/src/common.ts new file mode 100644 index 00000000000..dfc3a8d7823 --- /dev/null +++ b/packages/graphql-language-service-server/src/common.ts @@ -0,0 +1,53 @@ +import { glob } from 'glob'; +import { GraphQLProjectConfig } from 'graphql-config'; +import { + DEFAULT_SUPPORTED_EXTENSIONS, + DEFAULT_SUPPORTED_GRAPHQL_EXTENSIONS, +} from './constants'; + +export function unwrapProjectSchema(project: GraphQLProjectConfig): string[] { + const projectSchema = project.schema; + + const schemas: string[] = []; + if (typeof projectSchema === 'string') { + schemas.push(projectSchema); + } else if (Array.isArray(projectSchema)) { + for (const schemaEntry of projectSchema) { + if (typeof schemaEntry === 'string') { + schemas.push(schemaEntry); + } else if (schemaEntry) { + schemas.push(...Object.keys(schemaEntry)); + } + } + } else { + schemas.push(...Object.keys(projectSchema)); + } + + return schemas.reduce((agg, schema) => { + const results = globIfFilePattern(schema); + return [...agg, ...results]; + }, []); +} +function globIfFilePattern(pattern: string) { + if (pattern.includes('*')) { + try { + return glob.sync(pattern); + // URLs may contain * characters + } catch {} + } + return [pattern]; +} +const allExtensions = [ + ...DEFAULT_SUPPORTED_EXTENSIONS, + ...DEFAULT_SUPPORTED_GRAPHQL_EXTENSIONS, +]; +// only local schema lookups if all of the schema entries are local files +export function isProjectSDLOnly(unwrappedSchema: string[]): boolean { + return unwrappedSchema.every(schemaEntry => + allExtensions.some( + // local schema file URIs for lookup don't start with http, and end with an extension. + // though it isn't often used, technically schema config could include a remote .graphql file + ext => !schemaEntry.startsWith('http') && schemaEntry.endsWith(ext), + ), + ); +} diff --git a/packages/graphql-language-service-server/src/findGraphQLTags.ts b/packages/graphql-language-service-server/src/findGraphQLTags.ts index 4db6d001aed..a155df5bc3e 100644 --- a/packages/graphql-language-service-server/src/findGraphQLTags.ts +++ b/packages/graphql-language-service-server/src/findGraphQLTags.ts @@ -83,7 +83,12 @@ export function findGraphQLTags( 'arguments' in node ) { const templateLiteral = node.arguments[0]; - if (templateLiteral && templateLiteral.type === 'TemplateLiteral') { + if ( + templateLiteral && + (templateLiteral.type === 'TemplateLiteral' || + templateLiteral.type === 'TaggedTemplateExpression') + ) { + // @ts-expect-error const parsed = parseTemplateLiteral(templateLiteral, rangeMapper); if (parsed) { result.push(parsed); @@ -97,9 +102,19 @@ export function findGraphQLTags( const tagName = getGraphQLTagName(node.tag); if (tagName) { const { loc } = node.quasi.quasis[0]; + const template = node.quasi.quasis.length > 1 - ? node.quasi.quasis.map(quasi => quasi.value.raw).join('') + ? node.quasi.quasis + .map((quasi, i) => + i === node.quasi.quasis?.length - 1 + ? quasi.value.raw + : getReplacementString( + quasi.value.raw, + node.quasi.quasis[i + 1].value.raw, + ), + ) + .join('') : node.quasi.quasis[0].value.raw; // handle template literals with N line expressions if (loc && node.quasi.quasis.length > 1) { @@ -148,6 +163,28 @@ export function findGraphQLTags( return result; } +/* + Here we inject replacements for template tag literal expressions, + so that graphql parse & thus validation can be performed, + and we don't get or expected name parse errors + + TODO: other user reported cases to consider: + 1. operation field argument values - though we recommend graphql variables + 2. fragment spreads (maybe fragment variables will help solve this?) + + these might be extra difficult because they may require type introspection + 3. directive argument default values + 5. default argument values for input types +*/ +const getReplacementString = (quasi: string, nextQuasi: string) => { + const trimmed = quasi.trimEnd(); + const trimmedNext = nextQuasi.trimStart(); + // only actually empty leaf field expressions + if (trimmed.endsWith('{') && trimmedNext.startsWith('}')) { + return quasi + '__typename'; + } + return quasi; +}; /** * Parses a Babel AST template literal into a GraphQL tag. */ @@ -157,15 +194,20 @@ function parseTemplateLiteral(node: TemplateLiteral, rangeMapper: RangeMapper) { // handle template literals with N line expressions if (node.quasis.length > 1) { - const last = node.quasis.pop(); + const quasis = [...node.quasis]; + const last = quasis.pop(); if (last?.loc?.end) { loc.end = last.loc.end; } } - const template = - node.quasis.length > 1 - ? node.quasis.map(quasi => quasi.value.raw).join('') - : node.quasis[0].value.raw; + const template = node.quasis + .map((quasi, i) => + i === node.quasis?.length - 1 + ? quasi.value.raw + : getReplacementString(quasi.value.raw, node.quasis[i + 1].value.raw), + ) + .join(''); + const range = rangeMapper( new Range( new Position(loc.start.line - 1, loc.start.column), diff --git a/packages/graphql-language-service-server/src/index.ts b/packages/graphql-language-service-server/src/index.ts index a1be21b2e92..4dad43d2045 100644 --- a/packages/graphql-language-service-server/src/index.ts +++ b/packages/graphql-language-service-server/src/index.ts @@ -14,3 +14,4 @@ export { default as startServer } from './startServer'; export * from './GraphQLCache'; export * from './parseDocument'; export * from './findGraphQLTags'; +export * from './types'; diff --git a/packages/graphql-language-service-server/src/parsers/astro.ts b/packages/graphql-language-service-server/src/parsers/astro.ts index 0b870fdaa30..f00c412f331 100644 --- a/packages/graphql-language-service-server/src/parsers/astro.ts +++ b/packages/graphql-language-service-server/src/parsers/astro.ts @@ -1,7 +1,9 @@ -import { parse } from 'astrojs-compiler-sync'; import { Position, Range } from 'graphql-language-service'; import { RangeMapper, SourceParser } from './types'; import { babelParser } from './babel'; +import { parse } from 'astrojs-compiler-sync'; + +// import { teardown } from '@astrojs/compiler/dist/node'; type ParseAstroResult = | { type: 'error'; errors: string[] } @@ -14,6 +16,7 @@ type ParseAstroResult = function parseAstro(source: string): ParseAstroResult { // eslint-disable-next-line unicorn/no-useless-undefined const { ast, diagnostics } = parse(source, undefined); + if (diagnostics.some(d => d.severity === /* Error */ 1)) { return { type: 'error', @@ -44,11 +47,11 @@ function parseAstro(source: string): ParseAstroResult { export const astroParser: SourceParser = (text, uri, logger) => { const parseAstroResult = parseAstro(text); if (parseAstroResult.type === 'error') { - logger.error( + logger.info( `Could not parse the astro file at ${uri} to extract the graphql tags:`, ); for (const error of parseAstroResult.errors) { - logger.error(String(error)); + logger.info(String(error)); } return null; } diff --git a/packages/graphql-language-service-server/src/parsers/babel.ts b/packages/graphql-language-service-server/src/parsers/babel.ts index aa2c37bd33a..4216c11a50e 100644 --- a/packages/graphql-language-service-server/src/parsers/babel.ts +++ b/packages/graphql-language-service-server/src/parsers/babel.ts @@ -15,10 +15,10 @@ export const ecmaParser: SourceParser = (text, uri, logger) => { try { return { asts: [babelParser(text, ['flow', 'flowComments'])] }; } catch (error) { - logger.error( + logger.info( `Could not parse the JavaScript file at ${uri} to extract the graphql tags:`, ); - logger.error(String(error)); + logger.info(String(error)); return null; } }; @@ -27,10 +27,10 @@ export const tsParser: SourceParser = (text, uri, logger) => { try { return { asts: [babelParser(text, ['typescript'])] }; } catch (error) { - logger.error( + logger.info( `Could not parse the TypeScript file at ${uri} to extract the graphql tags:`, ); - logger.error(String(error)); + logger.info(String(error)); return null; } }; diff --git a/packages/graphql-language-service-server/src/parsers/svelte.ts b/packages/graphql-language-service-server/src/parsers/svelte.ts index f19178b6239..e838271ff29 100644 --- a/packages/graphql-language-service-server/src/parsers/svelte.ts +++ b/packages/graphql-language-service-server/src/parsers/svelte.ts @@ -36,10 +36,10 @@ export const svelteParser: SourceParser = (text, uri, logger) => { rangeMapper, }; } catch (error) { - logger.error( + logger.info( `Could not parse the Svelte file at ${uri} to extract the graphql tags:`, ); - logger.error(String(error)); + logger.info(String(error)); return null; } }; diff --git a/packages/graphql-language-service-server/src/parsers/vue.ts b/packages/graphql-language-service-server/src/parsers/vue.ts index cdcb30de263..a1a80be2d52 100644 --- a/packages/graphql-language-service-server/src/parsers/vue.ts +++ b/packages/graphql-language-service-server/src/parsers/vue.ts @@ -49,11 +49,11 @@ export const vueParser: SourceParser = (text, uri, logger) => { const asts = []; const parseVueSFCResult = parseVueSFC(text); if (parseVueSFCResult.type === 'error') { - logger.error( + logger.info( `Could not parse the vue file at ${uri} to extract the graphql tags:`, ); for (const error of parseVueSFCResult.errors) { - logger.error(String(error)); + logger.info(String(error)); } return null; } diff --git a/packages/graphql-language-service-server/src/startServer.ts b/packages/graphql-language-service-server/src/startServer.ts index 69924e2ac98..eb0c999bc5c 100644 --- a/packages/graphql-language-service-server/src/startServer.ts +++ b/packages/graphql-language-service-server/src/startServer.ts @@ -6,9 +6,8 @@ * LICENSE file in the root directory of this source tree. * */ -import * as net from 'node:net'; import { MessageProcessor } from './MessageProcessor'; -import { GraphQLConfig, GraphQLExtensionDeclaration } from 'graphql-config'; +import { GraphQLConfig } from 'graphql-config'; import { IPCMessageReader, IPCMessageWriter, @@ -36,7 +35,7 @@ import { DocumentSymbolRequest, PublishDiagnosticsParams, WorkspaceSymbolRequest, - createConnection, + createConnection as createLanguageServerConnection, Connection, } from 'vscode-languageserver/node'; @@ -47,62 +46,8 @@ import { DEFAULT_SUPPORTED_GRAPHQL_EXTENSIONS, SupportedExtensionsEnum, } from './constants'; -import { LoadConfigOptions } from './types'; - -export interface ServerOptions { - /** - * port for the LSP server to run on. required if using method socket - */ - port?: number; - /** - * hostname if using socker - */ - hostname?: string; - /** - * socket, streams, or node (ipc). `node` by default. - */ - method?: 'socket' | 'stream' | 'node'; - /** - * `LoadConfigOptions` from `graphql-config@3` to use when we `loadConfig()` - * uses process.cwd() by default for `rootDir` option. - * you can also pass explicit `filepath`, add extensions, etc - */ - loadConfigOptions?: LoadConfigOptions; - /** - * (deprecated: use loadConfigOptions.rootDir now) the directory where graphql-config is found - */ - configDir?: string; - /** - * (deprecated: use loadConfigOptions.extensions now) array of functions to transform the graphql-config and add extensions dynamically - */ - extensions?: GraphQLExtensionDeclaration[]; - /** - * default: ['.js', '.jsx', '.tsx', '.ts', '.mjs'] - * allowed file extensions for embedded graphql, used by the parser. - * note that with vscode, this is also controlled by manifest and client configurations. - * do not put full-file graphql extensions here! - */ - fileExtensions?: ReadonlyArray; - /** - * default: ['graphql'] - allowed file extensions for graphql, used by the parser - */ - graphqlFileExtensions?: string[]; - /** - * pre-existing GraphQLConfig primitive, to override `loadConfigOptions` and related deprecated fields - */ - config?: GraphQLConfig; - /** - * custom, multi-language parser used by the LSP server. - * detects extension from uri and decides how to parse it. - * uses graphql.parse() by default - * response format is designed to assist with developing LSP tooling around embedded language support - */ - parser?: typeof parseDocument; - /** - * the temporary directory that the server writes to for logs and caching schema - */ - tmpDir?: string; -} +import { LoadConfigOptions, ServerOptions } from './types'; +import { createConnection } from 'node:net'; /** * Make loadConfigOptions @@ -115,10 +60,14 @@ export type MappedServerOptions = Omit & { * Legacy mappings for < 2.5.0 * @param options {ServerOptions} */ -const buildOptions = (options: ServerOptions): MappedServerOptions => { +export const buildOptions = (options: ServerOptions): MappedServerOptions => { const serverOptions = { ...options } as MappedServerOptions; + if (serverOptions.loadConfigOptions) { const { extensions, rootDir } = serverOptions.loadConfigOptions; + if (extensions) { + serverOptions.loadConfigOptions.extensions = extensions; + } if (!rootDir) { if (serverOptions.configDir) { serverOptions.loadConfigOptions.rootDir = serverOptions.configDir; @@ -126,16 +75,10 @@ const buildOptions = (options: ServerOptions): MappedServerOptions => { serverOptions.loadConfigOptions.rootDir = process.cwd(); } } - if (serverOptions.extensions) { - serverOptions.loadConfigOptions.extensions = [ - ...serverOptions.extensions, - ...(extensions || []), - ]; - } } else { serverOptions.loadConfigOptions = { rootDir: options.configDir || process.cwd(), - extensions: [], + extensions: serverOptions.extensions || [], }; } return serverOptions; @@ -148,61 +91,54 @@ const buildOptions = (options: ServerOptions): MappedServerOptions => { * @returns {Promise} */ export default async function startServer( - options: ServerOptions, -): Promise { - if (!options?.method) { - return; - } - const finalOptions = buildOptions(options); + options?: ServerOptions, +): Promise { + const finalOptions = buildOptions({ method: 'node', ...options }); let reader; let writer; - switch (options.method) { + switch (finalOptions.method) { case 'socket': // For socket connection, the message connection needs to be // established before the server socket starts listening. // Do that, and return at the end of this block. - if (!options.port) { + if (!finalOptions.port) { process.stderr.write( '--port is required to establish socket connection.', ); process.exit(1); } - const { port, hostname } = options; - const socket = net - .createServer(async client => { - client.setEncoding('utf8'); - reader = new SocketMessageReader(client); - writer = new SocketMessageWriter(client); - client.on('end', () => { - socket.close(); - process.exit(0); - }); - const s = await initializeHandlers({ - reader, - writer, - options: finalOptions, - }); - s.listen(); - }) - .listen(port, hostname); - return; - case 'stream': - reader = new StreamMessageReader(process.stdin); - writer = new StreamMessageWriter(process.stdout); + const { port, hostname, encoding } = finalOptions; + const socket = createConnection(port, hostname ?? '127.0.01'); + + reader = new SocketMessageReader(socket, encoding ?? 'utf-8'); + writer = new SocketMessageWriter(socket, encoding ?? 'utf-8'); + break; + case 'stream': + const server = createLanguageServerConnection( + // @ts-expect-error this still works, just a type mismatch + process.stdin, + process.stderr, + { + connectionStrategy: 'stdio', + }, + ); + server.listen(); + return server; + default: reader = new IPCMessageReader(process); writer = new IPCMessageWriter(process); break; } - - const serverWithHandlers = await initializeHandlers({ + const streamServer = await initializeHandlers({ reader, writer, options: finalOptions, }); - serverWithHandlers.listen(); + streamServer.listen(); + return streamServer; } type InitializerParams = { @@ -211,13 +147,13 @@ type InitializerParams = { options: MappedServerOptions; }; -async function initializeHandlers({ +export async function initializeHandlers({ reader, writer, options, }: InitializerParams): Promise { - const connection = createConnection(reader, writer); - const logger = new Logger(connection); + const connection = createLanguageServerConnection(reader, writer); + const logger = new Logger(connection, options.debug); try { await addHandlers({ connection, logger, ...options }); @@ -258,7 +194,7 @@ type HandlerOptions = { * * @param options {HandlerOptions} */ -async function addHandlers({ +export async function addHandlers({ connection, logger, config, @@ -306,8 +242,9 @@ async function addHandlers({ }, ); - connection.onNotification(DidCloseTextDocumentNotification.type, params => - messageProcessor.handleDidCloseNotification(params), + connection.onNotification( + DidCloseTextDocumentNotification.type, + messageProcessor.handleDidCloseNotification, ); connection.onRequest(ShutdownRequest.type, () => messageProcessor.handleShutdownRequest(), diff --git a/packages/graphql-language-service-server/src/types.ts b/packages/graphql-language-service-server/src/types.ts index 39aafe88dc7..cc3c6044121 100644 --- a/packages/graphql-language-service-server/src/types.ts +++ b/packages/graphql-language-service-server/src/types.ts @@ -1,2 +1,117 @@ -import type { loadConfig } from 'graphql-config'; +import type { + GraphQLExtensionDeclaration, + loadConfig, + GraphQLProjectConfig, +} from 'graphql-config'; export type LoadConfigOptions = Parameters[0]; +import { GraphQLConfig } from 'graphql-config'; +import { ASTNode, GraphQLType } from 'graphql'; +import { parseDocument } from './parseDocument'; +import { SupportedExtensionsEnum } from './constants'; + +// base 1 +type RangeType = { + start: { + line: number; + character: number; + }; + end: { + line: number; + character: number; + }; +}; + +type AdditionalLocateInfo = { + node?: ASTNode | null; + type?: GraphQLType | null; + project: GraphQLProjectConfig; +}; + +type RelayLSPLocateCommand = ( + // either Type, Type.field or Type.field(argument) + projectName: string, + typeName: string, + info: AdditionalLocateInfo, +) => `${string}:${string}:${string}` | `${string}:${string}` | string; + +type GraphQLLocateCommand = ( + projectName: string, + typeName: string, + info: AdditionalLocateInfo, +) => { + range: RangeType; + uri: string; +}; + +export type LocateCommand = RelayLSPLocateCommand | GraphQLLocateCommand; + +export interface ServerOptions { + /** + * socket, streams, or node (ipc). + * @default 'node' + */ + method?: 'socket' | 'stream' | 'node'; + /** + * (socket only) port for the LSP server to run on. required if using method socket + */ + port?: number; + /** + * (socket only) hostname for the LSP server to run on. + * @default '127.0.0.1' + */ + hostname?: string; + /** + * (socket only) encoding for the LSP server to use. + * @default 'utf-8' + */ + encoding?: 'utf-8' | 'ascii'; + /** + * `LoadConfigOptions` from `graphql-config@3` to use when we `loadConfig()` + * uses process.cwd() by default for `rootDir` option. + * you can also pass explicit `filepath`, add extensions, etc + */ + loadConfigOptions?: LoadConfigOptions; + /** + * @deprecated use loadConfigOptions.rootDir now) the directory where graphql-config is found + */ + configDir?: string; + /** + * @deprecated use loadConfigOptions.extensions + */ + extensions?: GraphQLExtensionDeclaration[]; + /** + * allowed file extensions for embedded graphql, used by the parser. + * note that with vscode, this is also controlled by manifest and client configurations. + * do not put full-file graphql extensions here! + * @default ['.js', '.jsx', '.tsx', '.ts', '.mjs'] + */ + fileExtensions?: ReadonlyArray; + /** + * allowed file extensions for full-file graphql, used by the parser. + * @default ['graphql', 'graphqls', 'gql' ] + */ + graphqlFileExtensions?: string[]; + /** + * pre-existing GraphQLConfig primitive, to override `loadConfigOptions` and related deprecated fields + */ + config?: GraphQLConfig; + /** + * custom, multi-language parser used by the LSP server. + * detects extension from uri and decides how to parse it. + * uses graphql.parse() by default + * response format is designed to assist with developing LSP tooling around embedded language support + */ + parser?: typeof parseDocument; + /** + * the temporary directory that the server writes to for logs and caching schema + */ + tmpDir?: string; + + /** + * debug mode + * + * same as with the client reference implementation, the debug setting controls logging output + * this allows all logger.info() messages to come through. by default, the highest level is warn + */ + debug?: true; +} diff --git a/packages/graphql-language-service/src/index.ts b/packages/graphql-language-service/src/index.ts index 9dad5cbdfab..e19879eeb5e 100644 --- a/packages/graphql-language-service/src/index.ts +++ b/packages/graphql-language-service/src/index.ts @@ -21,14 +21,12 @@ export { getDefinitionQueryResultForFragmentSpread, getDefinitionQueryResultForNamedType, getDefinitionQueryResultForField, - getDefinitionState, + getDefinitionQueryResultForArgument, getDiagnostics, - getFieldDef, getFragmentDefinitions, getHoverInformation, getOutline, getRange, - getTokenAtPosition, getTypeInfo, getVariableCompletions, SEVERITY, @@ -37,6 +35,7 @@ export { SeverityEnum, DIAGNOSTIC_SEVERITY, DefinitionQueryResult, + DefinitionQueryResponse, canUseDirective, SuggestionCommand, AutocompleteSuggestionOptions, @@ -57,6 +56,10 @@ export { list, t, opt, + getTokenAtPosition, + GraphQLDocumentMode, + getDefinitionState, + getFieldDef, } from './parser'; export type { diff --git a/packages/graphql-language-service/src/interface/__tests__/__schema__/HoverTestSchema.graphql b/packages/graphql-language-service/src/interface/__tests__/__schema__/HoverTestSchema.graphql index 4ab0c7de37e..3a03252fe25 100644 --- a/packages/graphql-language-service/src/interface/__tests__/__schema__/HoverTestSchema.graphql +++ b/packages/graphql-language-service/src/interface/__tests__/__schema__/HoverTestSchema.graphql @@ -19,6 +19,7 @@ enum Color { union UnionType = String | Float | Boolean interface TestInterface { + # hello id: String! } @@ -40,7 +41,7 @@ type Query { """ thing: TestType listOfThing: [TestType!] - parameterizedField(id: String!): TestType + parameterizedField(id: String!, enum: Color): TestType cluck: Chicken unionField: UnionType } diff --git a/packages/graphql-language-service/src/interface/__tests__/__schema__/StarWarsSchema.graphql b/packages/graphql-language-service/src/interface/__tests__/__schema__/StarWarsSchema.graphql index ba9fe3a55b4..c16bdf63983 100644 --- a/packages/graphql-language-service/src/interface/__tests__/__schema__/StarWarsSchema.graphql +++ b/packages/graphql-language-service/src/interface/__tests__/__schema__/StarWarsSchema.graphql @@ -14,6 +14,7 @@ enum Episode { interface Character { id: String! + # hello name: String friends: [Character] appearsIn: [Episode] @@ -30,6 +31,7 @@ type Human implements Character { type Droid implements Character { id: String! + # yes name: String friends: [Character] appearsIn: [Episode] @@ -41,13 +43,18 @@ type Droid implements Character { input InputType { key: String! value: Int = 42 + obj: InputType } interface TestInterface { + """ + example + """ testField: String! } interface AnotherInterface implements TestInterface { + # hello testField: String! } @@ -61,6 +68,7 @@ type Query { droid(id: String!): Droid inputTypeTest(args: InputType = { key: "key" }): TestType deprecatedField: TestType @deprecated(reason: "Use test instead.") + union: TestUnion } union TestUnion = Droid | TestType diff --git a/packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions-test.ts b/packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions-test.ts index f7daabfe2f5..e784200f9b4 100644 --- a/packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions-test.ts +++ b/packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions-test.ts @@ -19,11 +19,18 @@ import { GraphQLSchema, parse, version as graphQLVersion, + GraphQLString, + GraphQLInt, + GraphQLBoolean, + GraphQLDeprecatedDirective, + GraphQLSkipDirective, + GraphQLIncludeDirective, } from 'graphql'; import { Position } from '../../utils'; import path from 'node:path'; import { getAutocompleteSuggestions } from '../getAutocompleteSuggestions'; +// import { InsertTextFormat } from 'vscode-languageserver-types'; const expectedResults = { droid: { @@ -50,6 +57,15 @@ const expectedResults = { label: 'friends', detail: '[Character]', }, + union: { + label: 'union', + detail: 'TestUnion', + }, + __typename: { + label: '__typename', + detail: 'String!', + documentation: 'The name of the current Object type at runtime.', + }, }; const suggestionCommand = { @@ -74,7 +90,7 @@ describe('getAutocompleteSuggestions', () => { query: string, point: Position, externalFragments?: FragmentDefinitionNode[], - options?: AutocompleteSuggestionOptions, + options?: AutocompleteSuggestionOptions & { ignoreInsert?: boolean }, ): Array { return getAutocompleteSuggestions( schema, @@ -93,30 +109,40 @@ describe('getAutocompleteSuggestions', () => { if (suggestion.detail) { response.detail = String(suggestion.detail); } - if (suggestion.insertText) { + if (suggestion.insertText && !options?.ignoreInsert) { response.insertText = suggestion.insertText; } - if (suggestion.insertTextFormat) { + if (suggestion.insertTextFormat && !options?.ignoreInsert) { response.insertTextFormat = suggestion.insertTextFormat; } - if (suggestion.command) { + if (suggestion.command && !options?.ignoreInsert) { response.command = suggestion.command; } + if (suggestion.documentation?.length) { + response.documentation = suggestion.documentation; + } + if (suggestion.labelDetails && !options?.ignoreInsert) { + response.labelDetails = suggestion.labelDetails; + } + return response; }); } describe('with Operation types', () => { const expectedDirectiveSuggestions = [ - { label: 'include' }, - { label: 'skip' }, + { label: 'include', documentation: GraphQLIncludeDirective.description }, + { label: 'skip', documentation: GraphQLSkipDirective.description }, ]; // TODO: remove this once defer and stream are merged to `graphql` if (graphQLVersion.startsWith('16.0.0-experimental-stream-defer')) { + // @ts-expect-error expectedDirectiveSuggestions.push({ label: 'stream' }, { label: 'test' }); } else { + // @ts-expect-error expectedDirectiveSuggestions.push({ label: 'test' }); } + it('provides correct sortText response', () => { const result = getAutocompleteSuggestions( schema, @@ -137,7 +163,7 @@ describe('getAutocompleteSuggestions', () => { }, { - sortText: '6__schema', + sortText: '7__schema', label: '__schema', detail: '__Schema!', }, @@ -147,10 +173,17 @@ describe('getAutocompleteSuggestions', () => { it('provides correct initial keywords', () => { expect(testSuggestions('', new Position(0, 0))).toEqual([ { label: '{' }, + { label: 'extend' }, { label: 'fragment' }, + { label: 'input' }, + { label: 'interface' }, { label: 'mutation' }, { label: 'query' }, + { label: 'scalar' }, + { label: 'schema' }, { label: 'subscription' }, + { label: 'type' }, + { label: 'union' }, ]); expect(testSuggestions('q', new Position(0, 1))).toEqual([ @@ -159,9 +192,9 @@ describe('getAutocompleteSuggestions', () => { ]); }); - it('provides correct suggestions at where the cursor is', () => { + it('provides correct top level suggestions when a simple query is already present', () => { // Below should provide initial keywords - expect(testSuggestions(' {}', new Position(0, 0))).toEqual([ + expect(testSuggestions(' { id }', new Position(0, 0))).toEqual([ { label: '{' }, { label: 'fragment' }, { label: 'mutation' }, @@ -170,12 +203,17 @@ describe('getAutocompleteSuggestions', () => { ]); // Below should provide root field names - expect(testSuggestions(' {}', new Position(0, 2))).toEqual([ - { label: '__typename', detail: 'String!' }, + expect( + testSuggestions(' {}', new Position(0, 2), [], { + ignoreInsert: true, + }), + ).toEqual([ + expectedResults.__typename, expectedResults.droid, expectedResults.hero, expectedResults.human, expectedResults.inputTypeTest, + expectedResults.union, ]); // Test for query text with empty lines @@ -187,29 +225,39 @@ describe('getAutocompleteSuggestions', () => { } `, new Position(2, 0), + [], + { + ignoreInsert: true, + }, ), ).toEqual([ - { label: '__typename', detail: 'String!' }, + expectedResults.__typename, expectedResults.droid, expectedResults.hero, expectedResults.human, expectedResults.inputTypeTest, + expectedResults.union, ]); }); it('provides correct field name suggestions', () => { - const result = testSuggestions('{ ', new Position(0, 2)); + const result = testSuggestions('{ ', new Position(0, 2), [], { + ignoreInsert: true, + }); expect(result).toEqual([ - { label: '__typename', detail: 'String!' }, + expectedResults.__typename, expectedResults.droid, expectedResults.hero, expectedResults.human, expectedResults.inputTypeTest, + expectedResults.union, ]); }); it('provides correct field name suggestions after filtered', () => { - const result = testSuggestions('{ h ', new Position(0, 3)); + const result = testSuggestions('{ h ', new Position(0, 3), [], { + ignoreInsert: true, + }); expect(result).toEqual([expectedResults.hero, expectedResults.human]); }); @@ -217,10 +265,14 @@ describe('getAutocompleteSuggestions', () => { const result = testSuggestions( '{ alias: human(id: "1") { ', new Position(0, 26), + [], + { + ignoreInsert: true, + }, ); expect(result).toEqual([ - { label: '__typename', detail: 'String!' }, + expectedResults.__typename, expectedResults.appearsIn, expectedResults.friends, { label: 'id', detail: 'String!' }, @@ -229,6 +281,59 @@ describe('getAutocompleteSuggestions', () => { ]); }); + it('provides correct field name suggestions with insertText', () => { + const result = testSuggestions('{ ', new Position(0, 2), [], { + ignoreInsert: false, + fillLeafsOnComplete: true, + }); + expect(result).toEqual([ + { + ...expectedResults.__typename, + command: suggestionCommand, + insertTextFormat: 2, + insertText: '__typename\n', + labelDetails: { detail: ' String!' }, + }, + { + ...expectedResults.droid, + command: suggestionCommand, + insertTextFormat: 2, + insertText: 'droid(id: $1) {\n $1\n}', + labelDetails: { detail: ' Droid' }, + }, + { + ...expectedResults.hero, + command: suggestionCommand, + insertTextFormat: 2, + insertText: 'hero {\n $1\n}', + labelDetails: { detail: ' Character' }, + }, + { + ...expectedResults.human, + command: suggestionCommand, + insertTextFormat: 2, + insertText: 'human(id: $1) {\n $1\n}', + labelDetails: { detail: ' Human' }, + }, + { + ...expectedResults.inputTypeTest, + command: suggestionCommand, + insertTextFormat: 2, + insertText: 'inputTypeTest {\n $1\n}', + labelDetails: { detail: ' TestType' }, + }, + { + label: 'union', + insertTextFormat: 2, + insertText: 'union {\n $1\n}', + detail: 'TestUnion', + command: suggestionCommand, + labelDetails: { + detail: ' TestUnion', + }, + }, + ]); + }); it('provides correct type suggestions for fragments', () => { const result = testSuggestions('fragment test on ', new Position(0, 17)); @@ -248,10 +353,14 @@ describe('getAutocompleteSuggestions', () => { const result = testSuggestions( 'fragment test on Human { ', new Position(0, 25), + [], + { + ignoreInsert: true, + }, ); expect(result).toEqual([ - { label: '__typename', detail: 'String!' }, + expectedResults.__typename, expectedResults.appearsIn, expectedResults.friends, { label: 'id', detail: 'String!' }, @@ -265,9 +374,10 @@ describe('getAutocompleteSuggestions', () => { expect(result).toEqual([ { label: 'id', - detail: 'String!', insertText: 'id: ', command: suggestionCommand, + insertTextFormat: 2, + labelDetails: { detail: ' String!' }, }, ]); }); @@ -280,26 +390,37 @@ describe('getAutocompleteSuggestions', () => { expect(result).toEqual([ { label: 'id', - detail: 'String!', command: suggestionCommand, insertText: 'id: ', + insertTextFormat: 2, + labelDetails: { detail: ' String!' }, }, ]); }); - + const metaArgs = [ + { + label: '__DirectiveLocation', + documentation: + 'A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.', + }, + { + label: '__TypeKind', + documentation: + 'An enum describing what kind of type a given `__Type` is.', + }, + ]; it('provides correct input type suggestions', () => { const result = testSuggestions( 'query($exampleVariable: ) { ', new Position(0, 24), ); expect(result).toEqual([ - { label: '__DirectiveLocation' }, - { label: '__TypeKind' }, - { label: 'Boolean' }, + ...metaArgs, + { label: 'Boolean', documentation: GraphQLBoolean.description }, { label: 'Episode' }, { label: 'InputType' }, - { label: 'Int' }, - { label: 'String' }, + { label: 'Int', documentation: GraphQLInt.description }, + { label: 'String', documentation: GraphQLString.description }, ]); }); @@ -309,11 +430,11 @@ describe('getAutocompleteSuggestions', () => { new Position(0, 26), ); expect(result).toEqual([ - { label: '__DirectiveLocation' }, - { label: '__TypeKind' }, + ...metaArgs, + { label: 'InputType' }, - { label: 'Int' }, - { label: 'String' }, + { label: 'Int', documentation: GraphQLInt.description }, + { label: 'String', documentation: GraphQLString.description }, ]); }); @@ -378,7 +499,7 @@ describe('getAutocompleteSuggestions', () => { new Position(0, 51), ); expect(result).toEqual([ - { label: 'ep', insertText: '$ep', detail: 'Episode' }, + { label: '$ep', insertText: 'ep', detail: 'Episode' }, ]); }); @@ -388,8 +509,9 @@ describe('getAutocompleteSuggestions', () => { new Position(0, 55), ); expect(result).toEqual([ + { label: '$episode', detail: 'Episode', insertText: '$episode' }, { label: 'EMPIRE', detail: 'Episode' }, - { label: 'episode', detail: 'Episode', insertText: '$episode' }, + { label: 'JEDI', detail: 'Episode' }, { label: 'NEWHOPE', detail: 'Episode' }, // no $id here, it's not compatible :P @@ -405,13 +527,27 @@ describe('getAutocompleteSuggestions', () => { `${fragmentDef} query { human(id: "1") { ...`, new Position(0, 57), ), - ).toEqual([{ label: 'Foo', detail: 'Human' }]); + ).toEqual([ + { + label: 'Foo', + detail: 'Human', + documentation: 'fragment Foo on Human', + labelDetails: { detail: 'fragment Foo on Human' }, + }, + ]); expect( testSuggestions( `query { human(id: "1") { ... }} ${fragmentDef}`, new Position(0, 28), ), - ).toEqual([{ label: 'Foo', detail: 'Human' }]); + ).toEqual([ + { + label: 'Foo', + detail: 'Human', + documentation: 'fragment Foo on Human', + labelDetails: { detail: 'fragment Foo on Human' }, + }, + ]); // Test on abstract type expect( @@ -419,7 +555,14 @@ describe('getAutocompleteSuggestions', () => { `${fragmentDef} query { hero(episode: JEDI) { ...`, new Position(0, 62), ), - ).toEqual([{ label: 'Foo', detail: 'Human' }]); + ).toEqual([ + { + label: 'Foo', + detail: 'Human', + documentation: 'fragment Foo on Human', + labelDetails: { detail: 'fragment Foo on Human' }, + }, + ]); }); it('provides correct fragment name suggestions for external fragments', () => { @@ -439,8 +582,18 @@ describe('getAutocompleteSuggestions', () => { ); expect(result).toEqual([ - { label: 'CharacterDetails', detail: 'Human' }, - { label: 'CharacterDetails2', detail: 'Human' }, + { + label: 'CharacterDetails', + detail: 'Human', + documentation: 'fragment CharacterDetails on Human', + labelDetails: { detail: 'fragment CharacterDetails on Human' }, + }, + { + label: 'CharacterDetails2', + detail: 'Human', + documentation: 'fragment CharacterDetails2 on Human', + labelDetails: { detail: 'fragment CharacterDetails2 on Human' }, + }, ]); }); @@ -460,23 +613,69 @@ describe('getAutocompleteSuggestions', () => { expect(testSuggestions('query @', new Position(0, 7))).toEqual([]); }); - it('provides correct testInput suggestions', () => { + it('provides correct directive field suggestions', () => { expect( - testSuggestions('{ inputTypeTest(args: {', new Position(0, 23)), + testSuggestions('{ test @deprecated(', new Position(0, 19)), ).toEqual([ - { label: 'key', detail: 'String!' }, - { label: 'value', detail: 'Int' }, + { + command: suggestionCommand, + label: 'reason', + insertTextFormat: 2, + insertText: 'reason: ', + documentation: GraphQLDeprecatedDirective.args[0].description, + labelDetails: { + detail: ' String', + }, + }, ]); }); + const inputArgs = [ + { + label: 'key', + detail: 'String!', + insertText: 'key: ', + insertTextFormat: 2, + command: suggestionCommand, + }, + { + detail: 'InputType', + label: 'obj', + insertText: 'obj: {\n $1\n}', + command: suggestionCommand, + insertTextFormat: 2, + }, + { + label: 'value', + detail: 'Int', + insertText: 'value: ', + insertTextFormat: 2, + command: suggestionCommand, + }, + ]; + it('provides correct testInput type field suggestions', () => { + expect( + testSuggestions('{ inputTypeTest(args: {', new Position(0, 23)), + ).toEqual(inputArgs); + }); + + it('provides correct nested testInput type field suggestions', () => { + expect( + testSuggestions('{ inputTypeTest(args: { obj: {', new Position(0, 30)), + ).toEqual(inputArgs); + }); it('provides correct field name suggestion inside inline fragment', () => { expect( testSuggestions( 'fragment Foo on Character { ... on Human { }}', new Position(0, 42), + [], + { + ignoreInsert: true, + }, ), ).toEqual([ - { label: '__typename', detail: 'String!' }, + expectedResults.__typename, expectedResults.appearsIn, expectedResults.friends, { label: 'id', detail: 'String!' }, @@ -486,9 +685,16 @@ describe('getAutocompleteSuggestions', () => { // Type-less inline fragment assumes the type automatically expect( - testSuggestions('fragment Foo on Droid { ... { ', new Position(0, 30)), + testSuggestions( + 'fragment Foo on Droid { ... { ', + new Position(0, 30), + [], + { + ignoreInsert: true, + }, + ), ).toEqual([ - { label: '__typename', detail: 'String!' }, + expectedResults.__typename, expectedResults.appearsIn, expectedResults.friends, { label: 'id', detail: 'String!' }, @@ -501,7 +707,7 @@ describe('getAutocompleteSuggestions', () => { }); describe('with SDL types', () => { - it('provides correct initial keywords', () => { + it('provides correct initial keywords w/ graphqls', () => { expect( testSuggestions('', new Position(0, 0), [], { uri: 'schema.graphqls' }), ).toEqual([ @@ -515,6 +721,25 @@ describe('getAutocompleteSuggestions', () => { ]); }); + it('provides correct initial keywords w/out graphqls', () => { + expect( + testSuggestions('', new Position(0, 0), [], { uri: 'schema.graphql' }), + ).toEqual([ + { label: '{' }, + { label: 'extend' }, + { label: 'fragment' }, + { label: 'input' }, + { label: 'interface' }, + { label: 'mutation' }, + { label: 'query' }, + { label: 'scalar' }, + { label: 'schema' }, + { label: 'subscription' }, + { label: 'type' }, + { label: 'union' }, + ]); + }); + it('provides correct initial definition keywords', () => { expect( testSuggestions('type Type { field: String }\n\n', new Position(0, 31)), @@ -595,13 +820,81 @@ describe('getAutocompleteSuggestions', () => { expect(testSuggestions('type Type @', new Position(0, 11))).toEqual([ { label: 'onAllDefs' }, ])); - it('provides correct suggestions on object fields', () => + it('provides correct suggestions on object field w/ .graphqls', () => expect( testSuggestions('type Type {\n aField: s', new Position(0, 23), [], { uri: 'schema.graphqls', + ignoreInsert: true, + }), + ).toEqual([ + { label: 'Episode' }, + { label: 'String' }, + { label: 'TestInterface' }, + { label: 'TestType' }, + { label: 'TestUnion' }, + ])); + + it('provides correct argument type suggestions on directive definitions', () => + expect( + testSuggestions( + 'directive @skip(if: ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT', + new Position(0, 19), + [], + { + ignoreInsert: true, + }, + ), + ).toEqual([ + { label: 'Boolean' }, + { label: 'Episode' }, + { label: 'InputType' }, + { label: 'Int' }, + { label: 'String' }, + ])); + + it('provides correct suggestions on object fields', () => + expect( + testSuggestions('type Type {\n aField: s', new Position(0, 23), [], { + uri: 'schema.graphql', + ignoreInsert: true, + }), + ).toEqual([ + { label: 'Episode' }, + { label: 'String' }, + { label: 'TestInterface' }, + { label: 'TestType' }, + { label: 'TestUnion' }, + ])); + // TODO: shouldn't TestType and TestUnion be available here? + it('provides correct filtered suggestions on object fields in regular SDL files', () => + expect( + testSuggestions('type Type {\n aField: s', new Position(0, 23), [], { + uri: 'schema.graphql', + ignoreInsert: true, + }), + ).toEqual([ + { label: 'Episode' }, + { label: 'String' }, + { label: 'TestInterface' }, + { label: 'TestType' }, + { label: 'TestUnion' }, + ])); + it('provides correct unfiltered suggestions on object fields in regular SDL files', () => + expect( + testSuggestions('type Type {\n aField: ', new Position(0, 22), [], { + uri: 'schema.graphql', + ignoreInsert: true, }), ).toEqual([ + { label: 'AnotherInterface' }, + { label: 'Boolean' }, + { label: 'Character' }, + { label: 'Droid' }, { label: 'Episode' }, + { label: 'Human' }, + { label: 'Int' }, + // TODO: maybe filter out types attached to top level schema? + { label: 'Query' }, { label: 'String' }, { label: 'TestInterface' }, { label: 'TestType' }, @@ -611,6 +904,27 @@ describe('getAutocompleteSuggestions', () => { expect( testSuggestions('type Type {\n aField: []', new Position(0, 25), [], { uri: 'schema.graphqls', + ignoreInsert: true, + }), + ).toEqual([ + { label: 'AnotherInterface' }, + { label: 'Boolean' }, + { label: 'Character' }, + { label: 'Droid' }, + { label: 'Episode' }, + { label: 'Human' }, + { label: 'Int' }, + { label: 'Query' }, + { label: 'String' }, + { label: 'TestInterface' }, + { label: 'TestType' }, + { label: 'TestUnion' }, + ])); + it('provides correct suggestions on object fields that are arrays in SDL context', () => + expect( + testSuggestions('type Type {\n aField: []', new Position(0, 25), [], { + uri: 'schema.graphql', + ignoreInsert: true, }), ).toEqual([ { label: 'AnotherInterface' }, @@ -631,12 +945,18 @@ describe('getAutocompleteSuggestions', () => { testSuggestions('input Type {\n aField: s', new Position(0, 23), [], { uri: 'schema.graphqls', }), - ).toEqual([{ label: 'Episode' }, { label: 'String' }])); + ).toEqual([ + { label: 'Episode' }, + { label: 'String', documentation: GraphQLString.description }, + ])); it('provides correct directive suggestions on args definitions', () => expect( testSuggestions('type Type { field(arg: String @', new Position(0, 31)), ).toEqual([ - { label: 'deprecated' }, + { + label: 'deprecated', + documentation: GraphQLDeprecatedDirective.description, + }, { label: 'onAllDefs' }, { label: 'onArg' }, ])); diff --git a/packages/graphql-language-service/src/interface/__tests__/getHoverInformation-test.ts b/packages/graphql-language-service/src/interface/__tests__/getHoverInformation-test.ts index 450df17fe3c..21880895cef 100644 --- a/packages/graphql-language-service/src/interface/__tests__/getHoverInformation-test.ts +++ b/packages/graphql-language-service/src/interface/__tests__/getHoverInformation-test.ts @@ -101,6 +101,22 @@ describe('getHoverInformation', () => { expect(actual).toEqual('Query.parameterizedField(id: String!)'); }); + it('provides enum parameter type information', () => { + const actual = testHover( + 'query { parameterizedField(id: "foo", enum: GREEN) { testField } }', + new Position(0, 46), + ); + expect(actual).toEqual('Color.GREEN'); + }); + + it('provides variable type information', () => { + const actual = testHover( + 'query($who: String!) { parameterizedField(id: $who) { testField } }', + new Position(0, 48), + ); + expect(actual).toEqual('String!'); + }); + it('provides directive information', () => { const actual = testHover( 'query { thing { testField @skip(if:true) } }', diff --git a/packages/graphql-language-service/src/interface/autocompleteUtils.ts b/packages/graphql-language-service/src/interface/autocompleteUtils.ts index afd645e0153..ea482c02a92 100644 --- a/packages/graphql-language-service/src/interface/autocompleteUtils.ts +++ b/packages/graphql-language-service/src/interface/autocompleteUtils.ts @@ -9,76 +9,15 @@ import { GraphQLField, - GraphQLSchema, GraphQLType, - isCompositeType, - SchemaMetaFieldDef, - TypeMetaFieldDef, - TypeNameMetaFieldDef, + isListType, + isObjectType, + isInputObjectType, + getNamedType, + isAbstractType, } from 'graphql'; -import { CompletionItemBase, AllTypeInfo } from '../types'; -import { ContextTokenUnion, State } from '../parser'; - -// Utility for returning the state representing the Definition this token state -// is within, if any. -export function getDefinitionState( - tokenState: State, -): State | null | undefined { - let definitionState; - - // TODO - couldn't figure this one out - forEachState(tokenState, (state: State): void => { - switch (state.kind) { - case 'Query': - case 'ShortQuery': - case 'Mutation': - case 'Subscription': - case 'FragmentDefinition': - definitionState = state; - break; - } - }); - - return definitionState; -} - -// Gets the field definition given a type and field name -export function getFieldDef( - schema: GraphQLSchema, - type: GraphQLType, - fieldName: string, -): GraphQLField | null | undefined { - if (fieldName === SchemaMetaFieldDef.name && schema.getQueryType() === type) { - return SchemaMetaFieldDef; - } - if (fieldName === TypeMetaFieldDef.name && schema.getQueryType() === type) { - return TypeMetaFieldDef; - } - if (fieldName === TypeNameMetaFieldDef.name && isCompositeType(type)) { - return TypeNameMetaFieldDef; - } - if ('getFields' in type) { - return type.getFields()[fieldName] as any; - } - - return null; -} - -// Utility for iterating through a CodeMirror parse state stack bottom-up. -export function forEachState( - stack: State, - fn: (state: State) => AllTypeInfo | null | void, -): void { - const reverseStateStack = []; - let state: State | null | undefined = stack; - while (state?.kind) { - reverseStateStack.push(state); - state = state.prevState; - } - for (let i = reverseStateStack.length - 1; i >= 0; i--) { - fn(reverseStateStack[i]); - } -} +import { CompletionItemBase } from '../types'; +import { ContextTokenUnion } from '../parser'; export function objectValues(object: Record): Array { const keys = Object.keys(object); @@ -104,7 +43,12 @@ function filterAndSortList( list: Array, text: string, ): Array { - if (!text) { + if ( + !text || + text.trim() === '' || + text.trim() === ':' || + text.trim() === '{' + ) { return filterNonEmpty(list, entry => !entry.isDeprecated); } @@ -200,3 +144,63 @@ function lexicalDistance(a: string, b: string): number { return d[aLength][bLength]; } + +const insertSuffix = (n?: number) => ` {\n $${n ?? 1}\n}`; + +export const getInsertText = ( + prefix: string, + type?: GraphQLType, + fallback?: string, +): string => { + if (!type) { + return fallback ?? prefix; + } + + const namedType = getNamedType(type); + if ( + isObjectType(namedType) || + isInputObjectType(namedType) || + isListType(namedType) || + isAbstractType(namedType) + ) { + return prefix + insertSuffix(); + } + + return fallback ?? prefix; +}; + +export const getInputInsertText = ( + prefix: string, + type: GraphQLType, + fallback?: string, +): string => { + // if (isScalarType(type) && type.name === GraphQLString.name) { + // return prefix + '"$1"'; + // } + if (isListType(type)) { + const baseType = getNamedType(type.ofType); + return prefix + `[${getInsertText('', baseType, '$1')}]`; + } + return getInsertText(prefix, type, fallback); +}; + +/** + * generates a TextSnippet for a field with possible required arguments + * that dynamically adjusts to the number of required arguments + * @param field + * @returns + */ +export const getFieldInsertText = (field: GraphQLField) => { + const requiredArgs = field.args.filter(arg => + arg.type.toString().endsWith('!'), + ); + if (!requiredArgs.length) { + return; + } + return ( + field.name + + `(${requiredArgs.map( + (arg, i) => `${arg.name}: $${i + 1}`, + )}) ${getInsertText('', field.type, '\n')}` + ); +}; diff --git a/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts b/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts index 5c01896a22d..4038fae331e 100644 --- a/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts +++ b/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts @@ -23,8 +23,7 @@ import { Kind, DirectiveLocation, GraphQLArgument, - isListType, - isNonNullType, + // isNonNullType, isScalarType, isObjectType, isUnionType, @@ -34,19 +33,16 @@ import { GraphQLBoolean, GraphQLEnumType, GraphQLInputObjectType, - GraphQLList, SchemaMetaFieldDef, TypeMetaFieldDef, TypeNameMetaFieldDef, assertAbstractType, doTypesOverlap, getNamedType, - getNullableType, isAbstractType, isCompositeType, isInputType, visit, - BREAK, parse, } from 'graphql'; @@ -58,24 +54,32 @@ import { InsertTextFormat, } from '../types'; -import { - CharacterStream, - onlineParser, +import type { ContextToken, State, - RuleKinds, RuleKind, ContextTokenForCodeMirror, } from '../parser'; - import { - forEachState, + getTypeInfo, + runOnlineParser, + RuleKinds, + getContextAtPosition, getDefinitionState, - getFieldDef, + GraphQLDocumentMode, +} from '../parser'; +import { hintList, objectValues, + getInputInsertText, + getFieldInsertText, + getInsertText, } from './autocompleteUtils'; +import { InsertTextMode } from 'vscode-languageserver-types'; + +export { runOnlineParser, getTypeInfo }; + export const SuggestionCommand = { command: 'editor.action.triggerSuggest', title: 'Suggestions', @@ -97,57 +101,30 @@ const collectFragmentDefs = (op: string | undefined) => { return externalFragments; }; -const typeSystemKinds: Kind[] = [ - // TypeSystemDefinition - Kind.SCHEMA_DEFINITION, - Kind.OPERATION_TYPE_DEFINITION, - Kind.SCALAR_TYPE_DEFINITION, - Kind.OBJECT_TYPE_DEFINITION, - Kind.INTERFACE_TYPE_DEFINITION, - Kind.UNION_TYPE_DEFINITION, - Kind.ENUM_TYPE_DEFINITION, - Kind.INPUT_OBJECT_TYPE_DEFINITION, - Kind.DIRECTIVE_DEFINITION, - // TypeSystemExtension - Kind.SCHEMA_EXTENSION, - Kind.SCALAR_TYPE_EXTENSION, - Kind.OBJECT_TYPE_EXTENSION, - Kind.INTERFACE_TYPE_EXTENSION, - Kind.UNION_TYPE_EXTENSION, - Kind.ENUM_TYPE_EXTENSION, - Kind.INPUT_OBJECT_TYPE_EXTENSION, -]; - -const hasTypeSystemDefinitions = (sdl: string | undefined) => { - let hasTypeSystemDef = false; - if (sdl) { - try { - visit(parse(sdl), { - enter(node) { - if (node.kind === 'Document') { - return; - } - if (typeSystemKinds.includes(node.kind)) { - hasTypeSystemDef = true; - return BREAK; - } - return false; - }, - }); - } catch { - return hasTypeSystemDef; - } - } - return hasTypeSystemDef; -}; - export type AutocompleteSuggestionOptions = { + /** + * EXPERIMENTAL: Automatically fill required leaf nodes recursively + * upon triggering code completion events. + * + * + * - [x] fills required nodes + * - [x] automatically expands relay-style node/edge fields + * - [ ] automatically jumps to first required argument field + * - then, continues to prompt for required argument fields + * - (fixing this will make it non-experimental) + * - when it runs out of arguments, or you choose `{` as a completion option + * that appears when all required arguments are supplied, the argument + * selection closes `)` and the leaf field expands again `{ \n| }` + */ fillLeafsOnComplete?: boolean; - schema?: GraphQLSchema; uri?: string; mode?: GraphQLDocumentMode; }; +type InternalAutocompleteOptions = AutocompleteSuggestionOptions & { + schema?: GraphQLSchema; +}; + /** * Given GraphQLSchema, queryText, and context of the current position within * the source text, provide a list of typeahead entries. @@ -163,29 +140,31 @@ export function getAutocompleteSuggestions( const opts = { ...options, schema, - }; - const token: ContextToken = - contextToken || getTokenAtPosition(queryText, cursor, 1); - - const state = - token.state.kind === 'Invalid' ? token.state.prevState : token.state; - - const mode = options?.mode || getDocumentMode(queryText, options?.uri); + } as InternalAutocompleteOptions; - // relieve flow errors by checking if `state` exists - if (!state) { + const context = getContextAtPosition( + queryText, + cursor, + schema, + contextToken, + options, + ); + if (!context) { return []; } + const { state, typeInfo, mode, token } = context; const { kind, step, prevState } = state; - const typeInfo = getTypeInfo(schema, token.state); // Definition kinds if (kind === RuleKinds.DOCUMENT) { if (mode === GraphQLDocumentMode.TYPE_SYSTEM) { return getSuggestionsForTypeSystemDefinitions(token); } - return getSuggestionsForExecutableDefinitions(token); + if (mode === GraphQLDocumentMode.EXECUTABLE) { + return getSuggestionsForExecutableDefinitions(token); + } + return getSuggestionsForUnknownDocumentMode(token); } if (kind === RuleKinds.EXTEND_DEF) { @@ -311,9 +290,13 @@ export function getAutocompleteSuggestions( argDefs.map( (argDef: GraphQLArgument): CompletionItem => ({ label: argDef.name, - insertText: argDef.name + ': ', + insertText: getInputInsertText(argDef.name + ': ', argDef.type), + insertTextMode: InsertTextMode.adjustIndentation, + insertTextFormat: InsertTextFormat.Snippet, command: SuggestionCommand, - detail: String(argDef.type), + labelDetails: { + detail: ' ' + String(argDef.type), + }, documentation: argDef.description ?? undefined, kind: CompletionItemKind.Variable, type: argDef.type, @@ -339,9 +322,13 @@ export function getAutocompleteSuggestions( objectFields.map(field => ({ label: field.name, detail: String(field.type), - documentation: field.description ?? undefined, + documentation: field?.description ?? undefined, kind: completionKind, type: field.type, + insertText: getInputInsertText(field.name + ': ', field.type), + insertTextMode: InsertTextMode.adjustIndentation, + insertTextFormat: InsertTextFormat.Snippet, + command: SuggestionCommand, })), ); } @@ -355,7 +342,7 @@ export function getAutocompleteSuggestions( ) { return getSuggestionsForInputValues(token, typeInfo, queryText, schema); } - // complete for all variables available in the query + // complete for all variables available in the query scoped to this if (kind === RuleKinds.VARIABLE && step === 1) { const namedInputType = getNamedType(typeInfo.inputType!); const variableDefinitions = getVariableCompletions( @@ -399,34 +386,36 @@ export function getAutocompleteSuggestions( const unwrappedState = unwrapType(state); - if ( - (mode === GraphQLDocumentMode.TYPE_SYSTEM && - !unwrappedState.needsAdvance && - kind === RuleKinds.NAMED_TYPE) || - kind === RuleKinds.LIST_TYPE - ) { - if (unwrappedState.kind === RuleKinds.FIELD_DEF) { - return hintList( - token, - Object.values(schema.getTypeMap()) - .filter(type => isOutputType(type) && !type.name.startsWith('__')) - .map(type => ({ - label: type.name, - kind: CompletionItemKind.Function, - })), - ); - } - if (unwrappedState.kind === RuleKinds.INPUT_VALUE_DEF) { - return hintList( - token, - Object.values(schema.getTypeMap()) - .filter(type => isInputType(type) && !type.name.startsWith('__')) - .map(type => ({ - label: type.name, - kind: CompletionItemKind.Function, - })), - ); - } + if (unwrappedState.kind === RuleKinds.FIELD_DEF) { + return hintList( + token, + Object.values(schema.getTypeMap()) + .filter(type => isOutputType(type) && !type.name.startsWith('__')) + .map(type => ({ + label: type.name, + kind: CompletionItemKind.Function, + insertText: options?.fillLeafsOnComplete + ? type.name + '\n' + : type.name, + insertTextMode: InsertTextMode.adjustIndentation, + })), + ); + } + if (unwrappedState.kind === RuleKinds.INPUT_VALUE_DEF && step === 2) { + return hintList( + token, + Object.values(schema.getTypeMap()) + .filter(type => isInputType(type) && !type.name.startsWith('__')) + .map(type => ({ + label: type.name, + kind: CompletionItemKind.Function, + insertText: options?.fillLeafsOnComplete + ? type.name + '\n$1' + : type.name, + insertTextMode: InsertTextMode.adjustIndentation, + insertTextFormat: InsertTextFormat.Snippet, + })), + ); } // Variable definition types @@ -446,77 +435,62 @@ export function getAutocompleteSuggestions( if (kind === RuleKinds.DIRECTIVE) { return getSuggestionsForDirective(token, state, schema, kind); } + if (kind === RuleKinds.DIRECTIVE_DEF) { + return getSuggestionsForDirectiveArguments(token, state, schema, kind); + } return []; } -const insertSuffix = ' {\n $1\n}'; +const typeSystemCompletionItems = [ + { label: 'type', kind: CompletionItemKind.Function }, + { label: 'interface', kind: CompletionItemKind.Function }, + { label: 'union', kind: CompletionItemKind.Function }, + { label: 'input', kind: CompletionItemKind.Function }, + { label: 'scalar', kind: CompletionItemKind.Function }, + { label: 'schema', kind: CompletionItemKind.Function }, +]; -/** - * Choose carefully when to insert the `insertText`! - * @param field - * @returns - */ -const getInsertText = (field: GraphQLField) => { - const { type } = field; - if (isCompositeType(type)) { - return insertSuffix; - } - if (isListType(type) && isCompositeType(type.ofType)) { - return insertSuffix; - } - if (isNonNullType(type)) { - if (isCompositeType(type.ofType)) { - return insertSuffix; - } - if (isListType(type.ofType) && isCompositeType(type.ofType.ofType)) { - return insertSuffix; - } - } - return null; -}; +const executableCompletionItems = [ + { label: 'query', kind: CompletionItemKind.Function }, + { label: 'mutation', kind: CompletionItemKind.Function }, + { label: 'subscription', kind: CompletionItemKind.Function }, + { label: 'fragment', kind: CompletionItemKind.Function }, + { label: '{', kind: CompletionItemKind.Constructor }, +]; // Helper functions to get suggestions for each kinds function getSuggestionsForTypeSystemDefinitions(token: ContextToken) { return hintList(token, [ { label: 'extend', kind: CompletionItemKind.Function }, - { label: 'type', kind: CompletionItemKind.Function }, - { label: 'interface', kind: CompletionItemKind.Function }, - { label: 'union', kind: CompletionItemKind.Function }, - { label: 'input', kind: CompletionItemKind.Function }, - { label: 'scalar', kind: CompletionItemKind.Function }, - { label: 'schema', kind: CompletionItemKind.Function }, + ...typeSystemCompletionItems, ]); } function getSuggestionsForExecutableDefinitions(token: ContextToken) { + return hintList(token, executableCompletionItems); +} + +function getSuggestionsForUnknownDocumentMode(token: ContextToken) { return hintList(token, [ - { label: 'query', kind: CompletionItemKind.Function }, - { label: 'mutation', kind: CompletionItemKind.Function }, - { label: 'subscription', kind: CompletionItemKind.Function }, - { label: 'fragment', kind: CompletionItemKind.Function }, - { label: '{', kind: CompletionItemKind.Constructor }, + { label: 'extend', kind: CompletionItemKind.Function }, + ...executableCompletionItems, + ...typeSystemCompletionItems, ]); } function getSuggestionsForExtensionDefinitions(token: ContextToken) { - return hintList(token, [ - { label: 'type', kind: CompletionItemKind.Function }, - { label: 'interface', kind: CompletionItemKind.Function }, - { label: 'union', kind: CompletionItemKind.Function }, - { label: 'input', kind: CompletionItemKind.Function }, - { label: 'scalar', kind: CompletionItemKind.Function }, - { label: 'schema', kind: CompletionItemKind.Function }, - ]); + return hintList(token, typeSystemCompletionItems); } function getSuggestionsForFieldNames( token: ContextToken, typeInfo: AllTypeInfo, - options?: AutocompleteSuggestionOptions, + options?: InternalAutocompleteOptions, ): Array { if (typeInfo.parentType) { const { parentType } = typeInfo; + // const { parentType, fieldDef, argDefs } = typeInfo; let fields: GraphQLField[] = []; if ('getFields' in parentType) { fields = objectValues>( @@ -531,6 +505,7 @@ function getSuggestionsForFieldNames( if (parentType === options?.schema?.getQueryType()) { fields.push(SchemaMetaFieldDef, TypeMetaFieldDef); } + return hintList( token, fields.map((field, index) => { @@ -539,20 +514,39 @@ function getSuggestionsForFieldNames( sortText: String(index) + field.name, label: field.name, detail: String(field.type), + documentation: field.description ?? undefined, deprecated: Boolean(field.deprecationReason), isDeprecated: Boolean(field.deprecationReason), deprecationReason: field.deprecationReason, kind: CompletionItemKind.Field, + labelDetails: { + detail: ' ' + field.type.toString(), + }, + type: field.type, }; - if (options?.fillLeafsOnComplete) { - // TODO: fillLeafs capability - const insertText = getInsertText(field); - if (insertText) { - suggestion.insertText = field.name + insertText; + // const hasArgs = + // // token.state.needsAdvance && + // // @ts-expect-error + // parentType?._fields[field?.name]; + + suggestion.insertText = getFieldInsertText(field); + + // eslint-disable-next-line logical-assignment-operators + if (!suggestion.insertText) { + suggestion.insertText = getInsertText( + field.name, + field.type, + // if we are replacing a field with arguments, we don't want the extra line + field.name + (token.state.needsAdvance ? '' : '\n'), + ); + } + + if (suggestion.insertText) { suggestion.insertTextFormat = InsertTextFormat.Snippet; + suggestion.insertTextMode = InsertTextMode.adjustIndentation; suggestion.command = SuggestionCommand; } } @@ -576,7 +570,7 @@ function getSuggestionsForInputValues( queryText, schema, token, - ).filter(v => v.detail === namedInputType.name); + ).filter(v => v.detail === namedInputType?.name); if (namedInputType instanceof GraphQLEnumType) { const values = namedInputType.getValues(); @@ -769,7 +763,7 @@ function getSuggestionsForFragmentTypeConditions( const namedType = getNamedType(type); return { label: String(type), - documentation: namedType?.description || '', + documentation: (namedType?.description as string | undefined) || '', kind: CompletionItemKind.Field, }; }), @@ -821,6 +815,9 @@ function getSuggestionsForFragmentSpread( label: frag.name.value, detail: String(typeMap[frag.typeCondition.name.value]), documentation: `fragment ${frag.name.value} on ${frag.typeCondition.name.value}`, + labelDetails: { + detail: `fragment ${frag.name.value} on ${frag.typeCondition.name.value}`, + }, kind: CompletionItemKind.Field, type: typeMap[frag.typeCondition.name.value], })), @@ -851,6 +848,7 @@ export function getVariableCompletions( let variableName: null | string = null; let variableType: GraphQLInputObjectType | undefined | null; const definitions: Record = Object.create({}); + runOnlineParser(queryText, (_, state: State) => { // TODO: gather this as part of `AllTypeInfo`, as I don't think it's optimal to re-run the parser like this if (state?.kind === RuleKinds.VARIABLE && state.name) { @@ -866,12 +864,17 @@ export function getVariableCompletions( } if (variableName && variableType && !definitions[variableName]) { - // append `$` if the `token.string` is not already `$` - + // append `$` if the `token.string` is not already `$`, or describing a variable + // this appears to take care of it everywhere + const replaceString = + token.string === '$' || token?.state?.kind === 'Variable' + ? variableName + : '$' + variableName; definitions[variableName] = { detail: variableType.toString(), - insertText: token.string === '$' ? variableName : '$' + variableName, - label: variableName, // keep label the same for `codemirror-graphql` + insertText: replaceString, + label: '$' + variableName, + rawInsert: replaceString, type: variableType, kind: CompletionItemKind.Variable, } as CompletionItem; @@ -932,7 +935,7 @@ function getSuggestionsForVariableDefinition( // TODO: couldn't get Exclude<> working here inputTypes.map((type: GraphQLNamedType) => ({ label: type.name, - documentation: type.description!, + documentation: type?.description || '', kind: CompletionItemKind.Variable, })), ); @@ -952,7 +955,7 @@ function getSuggestionsForDirective( token, directives.map(directive => ({ label: directive.name, - documentation: directive.description || '', + documentation: directive?.description || '', kind: CompletionItemKind.Function, })), ); @@ -960,89 +963,23 @@ function getSuggestionsForDirective( return []; } -export function getTokenAtPosition( - queryText: string, - cursor: IPosition, - offset = 0, -): ContextToken { - let styleAtCursor = null; - let stateAtCursor = null; - let stringAtCursor = null; - const token = runOnlineParser(queryText, (stream, state, style, index) => { - if ( - index !== cursor.line || - stream.getCurrentPosition() + offset < cursor.character + 1 - ) { - return; - } - styleAtCursor = style; - stateAtCursor = { ...state }; - stringAtCursor = stream.current(); - return 'BREAK'; - }); - - // Return the state/style of parsed token in case those at cursor aren't - // available. - return { - start: token.start, - end: token.end, - string: stringAtCursor || token.string, - state: stateAtCursor || token.state, - style: styleAtCursor || token.style, - }; -} - -/** - * Provides an utility function to parse a given query text and construct a - * `token` context object. - * A token context provides useful information about the token/style that - * CharacterStream currently possesses, as well as the end state and style - * of the token. - */ -type callbackFnType = ( - stream: CharacterStream, +// I thought this added functionality somewhere, but I couldn't write any tests +// to execute it. I think it's handled as Arguments +function getSuggestionsForDirectiveArguments( + token: ContextToken, state: State, - style: string, - index: number, -) => void | 'BREAK'; - -export function runOnlineParser( - queryText: string, - callback: callbackFnType, -): ContextToken { - const lines = queryText.split('\n'); - const parser = onlineParser(); - let state = parser.startState(); - let style = ''; - - let stream: CharacterStream = new CharacterStream(''); - - for (let i = 0; i < lines.length; i++) { - stream = new CharacterStream(lines[i]); - while (!stream.eol()) { - style = parser.token(stream, state); - const code = callback(stream, state, style, i); - if (code === 'BREAK') { - break; - } - } - - // Above while loop won't run if there is an empty line. - // Run the callback one more time to catch this. - callback(stream, state, style, i); - - if (!state.kind) { - state = parser.startState(); - } - } - - return { - start: stream.getStartOfToken(), - end: stream.getCurrentPosition(), - string: stream.current(), - state, - style, - }; + schema: GraphQLSchema, + _kind: string, +): Array { + const directive = schema.getDirectives().find(d => d.name === state.name); + return hintList( + token, + directive?.args.map(arg => ({ + label: arg.name, + documentation: arg.description || '', + kind: CompletionItemKind.Field, + })) || [], + ); } export function canUseDirective( @@ -1103,204 +1040,6 @@ export function canUseDirective( return false; } -// Utility for collecting rich type information given any token's state -// from the graphql-mode parser. -export function getTypeInfo( - schema: GraphQLSchema, - tokenState: State, -): AllTypeInfo { - let argDef: AllTypeInfo['argDef']; - let argDefs: AllTypeInfo['argDefs']; - let directiveDef: AllTypeInfo['directiveDef']; - let enumValue: AllTypeInfo['enumValue']; - let fieldDef: AllTypeInfo['fieldDef']; - let inputType: AllTypeInfo['inputType']; - let objectTypeDef: AllTypeInfo['objectTypeDef']; - let objectFieldDefs: AllTypeInfo['objectFieldDefs']; - let parentType: AllTypeInfo['parentType']; - let type: AllTypeInfo['type']; - let interfaceDef: AllTypeInfo['interfaceDef']; - forEachState(tokenState, state => { - switch (state.kind) { - case RuleKinds.QUERY: - case 'ShortQuery': - type = schema.getQueryType(); - break; - case RuleKinds.MUTATION: - type = schema.getMutationType(); - break; - case RuleKinds.SUBSCRIPTION: - type = schema.getSubscriptionType(); - break; - case RuleKinds.INLINE_FRAGMENT: - case RuleKinds.FRAGMENT_DEFINITION: - if (state.type) { - type = schema.getType(state.type); - } - break; - case RuleKinds.FIELD: - case RuleKinds.ALIASED_FIELD: { - if (!type || !state.name) { - fieldDef = null; - } else { - fieldDef = parentType - ? getFieldDef(schema, parentType, state.name) - : null; - type = fieldDef ? fieldDef.type : null; - } - break; - } - case RuleKinds.SELECTION_SET: - parentType = getNamedType(type!); - break; - case RuleKinds.DIRECTIVE: - directiveDef = state.name ? schema.getDirective(state.name) : null; - break; - - case RuleKinds.INTERFACE_DEF: - if (state.name) { - objectTypeDef = null; - interfaceDef = new GraphQLInterfaceType({ - name: state.name, - interfaces: [], - fields: {}, - }); - } - - break; - - case RuleKinds.OBJECT_TYPE_DEF: - if (state.name) { - interfaceDef = null; - objectTypeDef = new GraphQLObjectType({ - name: state.name, - interfaces: [], - fields: {}, - }); - } - - break; - case RuleKinds.ARGUMENTS: { - if (state.prevState) { - switch (state.prevState.kind) { - case RuleKinds.FIELD: - argDefs = fieldDef && (fieldDef.args as GraphQLArgument[]); - break; - case RuleKinds.DIRECTIVE: - argDefs = - directiveDef && (directiveDef.args as GraphQLArgument[]); - break; - // TODO: needs more tests - case RuleKinds.ALIASED_FIELD: { - const name = state.prevState?.name; - if (!name) { - argDefs = null; - break; - } - const field = parentType - ? getFieldDef(schema, parentType, name) - : null; - if (!field) { - argDefs = null; - break; - } - argDefs = field.args as GraphQLArgument[]; - break; - } - default: - argDefs = null; - break; - } - } else { - argDefs = null; - } - break; - } - case RuleKinds.ARGUMENT: - if (argDefs) { - for (let i = 0; i < argDefs.length; i++) { - if (argDefs[i].name === state.name) { - argDef = argDefs[i]; - break; - } - } - } - inputType = argDef?.type; - break; - // TODO: needs tests - case RuleKinds.ENUM_VALUE: - const enumType = getNamedType(inputType!); - enumValue = - enumType instanceof GraphQLEnumType - ? enumType - .getValues() - .find((val: GraphQLEnumValue) => val.value === state.name) - : null; - break; - // TODO: needs tests - case RuleKinds.LIST_VALUE: - const nullableType = getNullableType(inputType!); - inputType = - nullableType instanceof GraphQLList ? nullableType.ofType : null; - break; - case RuleKinds.OBJECT_VALUE: - const objectType = getNamedType(inputType!); - objectFieldDefs = - objectType instanceof GraphQLInputObjectType - ? objectType.getFields() - : null; - break; - // TODO: needs tests - case RuleKinds.OBJECT_FIELD: - const objectField = - state.name && objectFieldDefs ? objectFieldDefs[state.name] : null; - inputType = objectField?.type; - - break; - case RuleKinds.NAMED_TYPE: - if (state.name) { - type = schema.getType(state.name); - } - // TODO: collect already extended interfaces of the type/interface we're extending - // here to eliminate them from the completion list - // because "type A extends B & C &" should not show completion options for B & C still. - - break; - } - }); - - return { - argDef, - argDefs, - directiveDef, - enumValue, - fieldDef, - inputType, - objectFieldDefs, - parentType, - type, - interfaceDef, - objectTypeDef, - }; -} - -export enum GraphQLDocumentMode { - TYPE_SYSTEM = 'TYPE_SYSTEM', - EXECUTABLE = 'EXECUTABLE', -} - -function getDocumentMode( - documentText: string, - uri?: string, -): GraphQLDocumentMode { - if (uri?.endsWith('.graphqls')) { - return GraphQLDocumentMode.TYPE_SYSTEM; - } - return hasTypeSystemDefinitions(documentText) - ? GraphQLDocumentMode.TYPE_SYSTEM - : GraphQLDocumentMode.EXECUTABLE; -} - function unwrapType(state: State): State { if ( state.prevState && diff --git a/packages/graphql-language-service/src/interface/getDefinition.ts b/packages/graphql-language-service/src/interface/getDefinition.ts index 952ca33db47..6b50d429d34 100644 --- a/packages/graphql-language-service/src/interface/getDefinition.ts +++ b/packages/graphql-language-service/src/interface/getDefinition.ts @@ -16,15 +16,27 @@ import { TypeDefinitionNode, ObjectTypeDefinitionNode, FieldDefinitionNode, + // printType, + // isNamedType, + // ArgumentNode, + InputValueDefinitionNode, + GraphQLType, } from 'graphql'; import { Definition, FragmentInfo, Uri, ObjectTypeInfo } from '../types'; import { locToRange, offsetToPosition, Range, Position } from '../utils'; +// import { getTypeInfo } from './getAutocompleteSuggestions'; export type DefinitionQueryResult = { queryRange: Range[]; definitions: Definition[]; + printedName?: string; +}; + +export type DefinitionQueryResponse = DefinitionQueryResult & { + node?: ASTNode | null; + type?: GraphQLType | null; }; export const LANGUAGE = 'GraphQL'; @@ -68,6 +80,7 @@ export async function getDefinitionQueryResultForNamedType( return { definitions, queryRange: definitions.map(_ => getRange(text, node)), + printedName: name, }; } @@ -104,6 +117,43 @@ export async function getDefinitionQueryResultForField( definitions, // TODO: seems like it's not using queryRange: [], + printedName: [typeName, fieldName].join('.'), + }; +} + +export async function getDefinitionQueryResultForArgument( + argumentName: string, + fieldName: string, + typeName: string, + dependencies: Array, +): Promise { + dependencies.filter( + ({ definition }) => definition.name && definition.name.value === typeName, + ); + + const definitions: Array = []; + + for (const { filePath, content, definition } of dependencies) { + const argDefinition = (definition as ObjectTypeDefinitionNode).fields + ?.find(item => item.name.value === fieldName) + ?.arguments?.find(item => item.name.value === argumentName); + if (argDefinition == null) { + continue; + } + + definitions.push( + getDefinitionForArgumentDefinition( + filePath || '', + content, + argDefinition, + ), + ); + } + return { + definitions, + // TODO: seems like it's not using + queryRange: [], + printedName: `${[typeName, fieldName].join('.')}(${argumentName})`, }; } @@ -124,10 +174,10 @@ export async function getDefinitionQueryResultForFragmentSpread( ({ filePath, content, definition }) => getDefinitionForFragmentDefinition(filePath || '', content, definition), ); - return { definitions, queryRange: definitions.map(_ => getRange(text, fragment)), + printedName: name, }; } @@ -139,6 +189,7 @@ export function getDefinitionQueryResultForDefinitionNode( return { definitions: [getDefinitionForFragmentDefinition(path, text, definition)], queryRange: definition.name ? [getRange(text, definition.name)] : [], + printedName: definition.name?.value, }; } @@ -201,3 +252,23 @@ function getDefinitionForFieldDefinition( projectRoot: path, }; } +// GraphQLString, +// eslint-disable-next-line sonarjs/no-identical-functions +function getDefinitionForArgumentDefinition( + path: Uri, + text: string, + definition: InputValueDefinitionNode, +): Definition { + const { name } = definition; + assert(name, 'Expected ASTNode to have a Name.'); + return { + path, + position: getPosition(text, definition), + range: getRange(text, definition), + name: name.value || '', + language: LANGUAGE, + // This is a file inside the project root, good enough for now + projectRoot: path, + }; +} +// GraphQLString, diff --git a/packages/graphql-language-service/src/interface/getHoverInformation.ts b/packages/graphql-language-service/src/interface/getHoverInformation.ts index 3b971231e99..1961f6ca0c5 100644 --- a/packages/graphql-language-service/src/interface/getHoverInformation.ts +++ b/packages/graphql-language-service/src/interface/getHoverInformation.ts @@ -20,11 +20,11 @@ import { GraphQLField, GraphQLFieldConfig, } from 'graphql'; -import { ContextToken } from '../parser'; +import type { ContextToken } from '../parser'; import { AllTypeInfo, IPosition } from '../types'; import { Hover } from 'vscode-languageserver-types'; -import { getTokenAtPosition, getTypeInfo } from './getAutocompleteSuggestions'; +import { getContextAtPosition } from '../parser'; export type HoverConfig = { useMarkdown?: boolean }; @@ -35,22 +35,21 @@ export function getHoverInformation( contextToken?: ContextToken, config?: HoverConfig, ): Hover['contents'] { - const token = contextToken || getTokenAtPosition(queryText, cursor); - - if (!schema || !token || !token.state) { + const options = { ...config, schema }; + const context = getContextAtPosition(queryText, cursor, schema, contextToken); + if (!context) { return ''; } - + const { typeInfo, token } = context; const { kind, step } = token.state; - const typeInfo = getTypeInfo(schema, token.state); - const options = { ...config, schema }; // Given a Schema and a Token, produce the contents of an info tooltip. // To do this, create a div element that we will render "into" and then pass // it to various rendering functions. if ( (kind === 'Field' && step === 0 && typeInfo.fieldDef) || - (kind === 'AliasedField' && step === 2 && typeInfo.fieldDef) + (kind === 'AliasedField' && step === 2 && typeInfo.fieldDef) || + (kind === 'ObjectField' && step === 0 && typeInfo.fieldDef) ) { const into: string[] = []; renderMdCodeStart(into, options); @@ -67,6 +66,14 @@ export function getHoverInformation( renderDescription(into, options, typeInfo.directiveDef); return into.join('').trim(); } + if (kind === 'Variable' && typeInfo.type) { + const into: string[] = []; + renderMdCodeStart(into, options); + renderType(into, typeInfo, options, typeInfo.type); + renderMdCodeEnd(into, options); + renderDescription(into, options, typeInfo.type); + return into.join('').trim(); + } if (kind === 'Argument' && step === 0 && typeInfo.argDef) { const into: string[] = []; renderMdCodeStart(into, options); @@ -109,7 +116,11 @@ function renderMdCodeEnd(into: string[], options: any) { } } -function renderField(into: string[], typeInfo: AllTypeInfo, options: any) { +export function renderField( + into: string[], + typeInfo: AllTypeInfo, + options: any, +) { renderQualifiedField(into, typeInfo, options); renderTypeAnnotation(into, typeInfo, options, typeInfo.type!); } @@ -130,7 +141,11 @@ function renderQualifiedField( text(into, fieldName); } -function renderDirective(into: string[], typeInfo: AllTypeInfo, _options: any) { +export function renderDirective( + into: string[], + typeInfo: AllTypeInfo, + _options: any, +) { if (!typeInfo.directiveDef) { return; } @@ -138,7 +153,7 @@ function renderDirective(into: string[], typeInfo: AllTypeInfo, _options: any) { text(into, name); } -function renderArg(into: string[], typeInfo: AllTypeInfo, options: any) { +export function renderArg(into: string[], typeInfo: AllTypeInfo, options: any) { if (typeInfo.directiveDef) { renderDirective(into, typeInfo, options); } else if (typeInfo.fieldDef) { @@ -166,7 +181,11 @@ function renderTypeAnnotation( renderType(into, typeInfo, options, t); } -function renderEnumValue(into: string[], typeInfo: AllTypeInfo, options: any) { +export function renderEnumValue( + into: string[], + typeInfo: AllTypeInfo, + options: any, +) { if (!typeInfo.enumValue) { return; } @@ -176,7 +195,7 @@ function renderEnumValue(into: string[], typeInfo: AllTypeInfo, options: any) { text(into, name); } -function renderType( +export function renderType( into: string[], typeInfo: AllTypeInfo, options: any, diff --git a/packages/graphql-language-service/src/parser/api.ts b/packages/graphql-language-service/src/parser/api.ts new file mode 100644 index 00000000000..f5fd4bf0675 --- /dev/null +++ b/packages/graphql-language-service/src/parser/api.ts @@ -0,0 +1,207 @@ +/** + * 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. + * + */ + +import { IPosition } from '..'; +import { + CharacterStream, + onlineParser, + ContextToken, + State, + getTypeInfo, +} from '.'; +import { BREAK, GraphQLSchema, Kind, parse, visit } from 'graphql'; + +export type ParserCallbackFn = ( + stream: CharacterStream, + state: State, + style: string, + index: number, +) => void | 'BREAK'; + +/** + * Provides an utility function to parse a given query text and construct a + * `token` context object. + * A token context provides useful information about the token/style that + * CharacterStream currently possesses, as well as the end state and style + * of the token. + */ +export function runOnlineParser( + queryText: string, + callback: ParserCallbackFn, +): ContextToken { + const lines = queryText.split('\n'); + const parser = onlineParser(); + let state = parser.startState(); + let style = ''; + + let stream: CharacterStream = new CharacterStream(''); + + for (let i = 0; i < lines.length; i++) { + stream = new CharacterStream(lines[i]); + while (!stream.eol()) { + style = parser.token(stream, state); + const code = callback(stream, state, style, i); + if (code === 'BREAK') { + break; + } + } + + // Above while loop won't run if there is an empty line. + // Run the callback one more time to catch this. + callback(stream, state, style, i); + + if (!state.kind) { + state = parser.startState(); + } + } + + return { + start: stream.getStartOfToken(), + end: stream.getCurrentPosition(), + string: stream.current(), + state, + style, + }; +} + +export enum GraphQLDocumentMode { + TYPE_SYSTEM = 'TYPE_SYSTEM', + EXECUTABLE = 'EXECUTABLE', + UNKNOWN = 'UNKNOWN', +} + +export const TYPE_SYSTEM_KINDS: Kind[] = [ + // TypeSystemDefinition + Kind.SCHEMA_DEFINITION, + Kind.OPERATION_TYPE_DEFINITION, + Kind.SCALAR_TYPE_DEFINITION, + Kind.OBJECT_TYPE_DEFINITION, + Kind.INTERFACE_TYPE_DEFINITION, + Kind.UNION_TYPE_DEFINITION, + Kind.ENUM_TYPE_DEFINITION, + Kind.INPUT_OBJECT_TYPE_DEFINITION, + Kind.DIRECTIVE_DEFINITION, + // TypeSystemExtension + Kind.SCHEMA_EXTENSION, + Kind.SCALAR_TYPE_EXTENSION, + Kind.OBJECT_TYPE_EXTENSION, + Kind.INTERFACE_TYPE_EXTENSION, + Kind.UNION_TYPE_EXTENSION, + Kind.ENUM_TYPE_EXTENSION, + Kind.INPUT_OBJECT_TYPE_EXTENSION, +]; + +const getParsedMode = (sdl: string | undefined): GraphQLDocumentMode => { + let mode = GraphQLDocumentMode.UNKNOWN; + if (sdl) { + try { + visit(parse(sdl), { + enter(node) { + if (node.kind === 'Document') { + mode = GraphQLDocumentMode.EXECUTABLE; + return; + } + if (TYPE_SYSTEM_KINDS.includes(node.kind)) { + mode = GraphQLDocumentMode.TYPE_SYSTEM; + return BREAK; + } + return false; + }, + }); + } catch { + return mode; + } + } + return mode; +}; + +export function getDocumentMode( + documentText: string, + uri?: string, +): GraphQLDocumentMode { + if (uri?.endsWith('.graphqls')) { + return GraphQLDocumentMode.TYPE_SYSTEM; + } + return getParsedMode(documentText); +} + +/** + * Given a query text and a cursor position, return the context token + */ +export function getTokenAtPosition( + queryText: string, + cursor: IPosition, + offset = 0, +): ContextToken { + let styleAtCursor = null; + let stateAtCursor = null; + let stringAtCursor = null; + const token = runOnlineParser(queryText, (stream, state, style, index) => { + if ( + index !== cursor.line || + stream.getCurrentPosition() + offset < cursor.character + 1 + ) { + return; + } + styleAtCursor = style; + stateAtCursor = { ...state }; + stringAtCursor = stream.current(); + return 'BREAK'; + }); + + // Return the state/style of parsed token in case those at cursor aren't + // available. + return { + start: token.start, + end: token.end, + string: stringAtCursor || token.string, + state: stateAtCursor || token.state, + style: styleAtCursor || token.style, + }; +} + +/** + * Returns the token, state, typeInfo and mode at the cursor position + * Used by getAutocompleteSuggestions + */ +export function getContextAtPosition( + queryText: string, + cursor: IPosition, + schema: GraphQLSchema, + contextToken?: ContextToken, + options?: { mode?: GraphQLDocumentMode; uri?: string }, +): { + token: ContextToken; + state: State; + typeInfo: ReturnType; + mode: GraphQLDocumentMode; +} | null { + const token: ContextToken = + contextToken || getTokenAtPosition(queryText, cursor, 1); + if (!token) { + return null; + } + + const state = + token.state.kind === 'Invalid' ? token.state.prevState : token.state; + if (!state) { + return null; + } + + // relieve flow errors by checking if `state` exists + + const typeInfo = getTypeInfo(schema, token.state); + const mode = options?.mode || getDocumentMode(queryText, options?.uri); + return { + token, + state, + typeInfo, + mode, + }; +} diff --git a/packages/graphql-language-service/src/parser/getTypeInfo.ts b/packages/graphql-language-service/src/parser/getTypeInfo.ts new file mode 100644 index 00000000000..f0f8d1ae8d7 --- /dev/null +++ b/packages/graphql-language-service/src/parser/getTypeInfo.ts @@ -0,0 +1,279 @@ +/** + * 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. + * + */ + +import { + GraphQLSchema, + GraphQLEnumValue, + GraphQLField, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLArgument, + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLList, + getNamedType, + getNullableType, + SchemaMetaFieldDef, + GraphQLType, + TypeMetaFieldDef, + TypeNameMetaFieldDef, + isCompositeType, +} from 'graphql'; + +import { AllTypeInfo } from '../types'; + +import { State, RuleKinds } from '.'; + +// Gets the field definition given a type and field name +export function getFieldDef( + schema: GraphQLSchema, + type: GraphQLType, + fieldName: string, +): GraphQLField | null | undefined { + if (fieldName === SchemaMetaFieldDef.name && schema.getQueryType() === type) { + return SchemaMetaFieldDef; + } + if (fieldName === TypeMetaFieldDef.name && schema.getQueryType() === type) { + return TypeMetaFieldDef; + } + if (fieldName === TypeNameMetaFieldDef.name && isCompositeType(type)) { + return TypeNameMetaFieldDef; + } + if ('getFields' in type) { + return type.getFields()[fieldName] as any; + } + + return null; +} + +// Utility for iterating through a CodeMirror parse state stack bottom-up. +export function forEachState( + stack: State, + fn: (state: State) => AllTypeInfo | null | void, +): void { + const reverseStateStack = []; + let state: State | null | undefined = stack; + while (state?.kind) { + reverseStateStack.push(state); + state = state.prevState; + } + for (let i = reverseStateStack.length - 1; i >= 0; i--) { + fn(reverseStateStack[i]); + } +} + +// Utility for returning the state representing the Definition this token state +// is within, if any. +export function getDefinitionState( + tokenState: State, +): State | null | undefined { + let definitionState; + + // TODO - couldn't figure this one out + forEachState(tokenState, (state: State): void => { + switch (state.kind) { + case 'Query': + case 'ShortQuery': + case 'Mutation': + case 'Subscription': + case 'FragmentDefinition': + definitionState = state; + break; + } + }); + + return definitionState; +} + +// Utility for collecting rich type information given any token's state +// from the graphql-mode parser. +export function getTypeInfo( + schema: GraphQLSchema, + tokenState: State, +): AllTypeInfo { + let argDef: AllTypeInfo['argDef']; + let argDefs: AllTypeInfo['argDefs']; + let directiveDef: AllTypeInfo['directiveDef']; + let enumValue: AllTypeInfo['enumValue']; + let fieldDef: AllTypeInfo['fieldDef']; + let inputType: AllTypeInfo['inputType']; + let objectTypeDef: AllTypeInfo['objectTypeDef']; + let objectFieldDefs: AllTypeInfo['objectFieldDefs']; + let parentType: AllTypeInfo['parentType']; + let type: AllTypeInfo['type']; + let interfaceDef: AllTypeInfo['interfaceDef']; + forEachState(tokenState, state => { + switch (state.kind) { + case RuleKinds.QUERY: + case 'ShortQuery': + type = schema.getQueryType(); + break; + case RuleKinds.MUTATION: + type = schema.getMutationType(); + break; + case RuleKinds.SUBSCRIPTION: + type = schema.getSubscriptionType(); + break; + case RuleKinds.INLINE_FRAGMENT: + case RuleKinds.FRAGMENT_DEFINITION: + if (state.type) { + type = schema.getType(state.type); + } + break; + case RuleKinds.FIELD: + case RuleKinds.ALIASED_FIELD: { + if (!type || !state.name) { + fieldDef = null; + } else { + fieldDef = parentType + ? getFieldDef(schema, parentType, state.name) + : null; + type = fieldDef ? fieldDef.type : null; + } + break; + } + case RuleKinds.SELECTION_SET: + parentType = getNamedType(type!); + break; + case RuleKinds.DIRECTIVE: + directiveDef = state.name ? schema.getDirective(state.name) : null; + break; + + case RuleKinds.INTERFACE_DEF: + if (state.name) { + objectTypeDef = null; + interfaceDef = new GraphQLInterfaceType({ + name: state.name, + interfaces: [], + fields: {}, + }); + } + + break; + + case RuleKinds.OBJECT_TYPE_DEF: + if (state.name) { + interfaceDef = null; + objectTypeDef = new GraphQLObjectType({ + name: state.name, + interfaces: [], + fields: {}, + }); + } + + break; + case RuleKinds.ARGUMENTS: { + if (state.prevState) { + switch (state.prevState.kind) { + case RuleKinds.FIELD: + argDefs = fieldDef && (fieldDef.args as GraphQLArgument[]); + break; + case RuleKinds.DIRECTIVE: + argDefs = + directiveDef && (directiveDef.args as GraphQLArgument[]); + break; + // TODO: needs more tests + case RuleKinds.ALIASED_FIELD: { + const name = state.prevState?.name; + if (!name) { + argDefs = null; + break; + } + const field = parentType + ? getFieldDef(schema, parentType, name) + : null; + if (!field) { + argDefs = null; + break; + } + argDefs = field.args as GraphQLArgument[]; + break; + } + default: + argDefs = null; + break; + } + } else { + argDefs = null; + } + break; + } + case RuleKinds.ARGUMENT: + if (argDefs) { + for (let i = 0; i < argDefs.length; i++) { + if (argDefs[i].name === state.name) { + argDef = argDefs[i]; + break; + } + } + } + inputType = argDef?.type; + break; + case RuleKinds.VARIABLE_DEFINITION: + case RuleKinds.VARIABLE: + type = inputType; + break; + // TODO: needs tests + case RuleKinds.ENUM_VALUE: + const enumType = getNamedType(inputType!); + enumValue = + enumType instanceof GraphQLEnumType + ? enumType + .getValues() + .find((val: GraphQLEnumValue) => val.value === state.name) + : null; + break; + // TODO: needs tests + case RuleKinds.LIST_VALUE: + const nullableType = getNullableType(inputType!); + inputType = + nullableType instanceof GraphQLList ? nullableType.ofType : null; + break; + case RuleKinds.OBJECT_VALUE: + const objectType = getNamedType(inputType!); + objectFieldDefs = + objectType instanceof GraphQLInputObjectType + ? objectType.getFields() + : null; + break; + // TODO: needs tests + case RuleKinds.OBJECT_FIELD: + const objectField = + state.name && objectFieldDefs ? objectFieldDefs[state.name] : null; + inputType = objectField?.type; + // @ts-expect-error + fieldDef = objectField as GraphQLField; + type = fieldDef ? fieldDef.type : null; + break; + case RuleKinds.NAMED_TYPE: + if (state.name) { + type = schema.getType(state.name); + } + // TODO: collect already extended interfaces of the type/interface we're extending + // here to eliminate them from the completion list + // because "type A extends B & C &" should not show completion options for B & C still. + + break; + } + }); + + return { + argDef, + argDefs, + directiveDef, + enumValue, + fieldDef, + inputType, + objectFieldDefs, + parentType, + type, + interfaceDef, + objectTypeDef, + }; +} diff --git a/packages/graphql-language-service/src/parser/index.ts b/packages/graphql-language-service/src/parser/index.ts index 913d7fb31b1..cb1352ae0c1 100644 --- a/packages/graphql-language-service/src/parser/index.ts +++ b/packages/graphql-language-service/src/parser/index.ts @@ -15,4 +15,15 @@ export { butNot, list, opt, p, t } from './RuleHelpers'; export { default as onlineParser, ParserOptions } from './onlineParser'; +export { + runOnlineParser, + type ParserCallbackFn, + getTokenAtPosition, + getContextAtPosition, + GraphQLDocumentMode, + getDocumentMode, +} from './api'; + +export { getTypeInfo, getDefinitionState, getFieldDef } from './getTypeInfo'; + export * from './types'; diff --git a/packages/graphql-language-service/src/parser/types.ts b/packages/graphql-language-service/src/parser/types.ts index 31010e39561..9d80ad795c3 100644 --- a/packages/graphql-language-service/src/parser/types.ts +++ b/packages/graphql-language-service/src/parser/types.ts @@ -84,6 +84,7 @@ export const AdditionalRuleKinds: _AdditionalRuleKinds = { IMPLEMENTS: 'Implements', VARIABLE_DEFINITIONS: 'VariableDefinitions', TYPE: 'Type', + VARIABLE: 'Variable', }; export type _AdditionalRuleKinds = { @@ -115,6 +116,7 @@ export type _AdditionalRuleKinds = { IMPLEMENTS: 'Implements'; VARIABLE_DEFINITIONS: 'VariableDefinitions'; TYPE: 'Type'; + VARIABLE: 'Variable'; }; export const RuleKinds = { @@ -122,7 +124,8 @@ export const RuleKinds = { ...AdditionalRuleKinds, }; -export type _RuleKinds = typeof Kind & typeof AdditionalRuleKinds; +export type _RuleKinds = Omit & + typeof AdditionalRuleKinds; export type RuleKind = _RuleKinds[keyof _RuleKinds]; export type RuleKindEnum = RuleKind; diff --git a/packages/graphql-language-service/src/types.ts b/packages/graphql-language-service/src/types.ts index 6e4d8c47626..6f014e87215 100644 --- a/packages/graphql-language-service/src/types.ts +++ b/packages/graphql-language-service/src/types.ts @@ -39,6 +39,8 @@ import type { GraphQLExtensionDeclaration, } from 'graphql-config'; +export { GraphQLDocumentMode } from './parser'; + export type { GraphQLConfig, GraphQLProjectConfig, @@ -50,11 +52,6 @@ export interface GraphQLCache { getProjectForFile: (uri: string) => GraphQLProjectConfig | void; - getObjectTypeDependencies: ( - query: string, - fragmentDefinitions: Map, - ) => Promise; - getObjectTypeDependenciesForAST: ( parsedQuery: ASTNode, fragmentDefinitions: Map, @@ -70,12 +67,6 @@ export interface GraphQLCache { contents: CachedContent[], ) => Promise; - updateObjectTypeDefinitionCache: ( - rootDir: Uri, - filePath: Uri, - exists: boolean, - ) => Promise; - getFragmentDependencies: ( query: string, fragmentDefinitions: Maybe>, @@ -95,15 +86,8 @@ export interface GraphQLCache { filePath: Uri, contents: CachedContent[], ) => Promise; - - updateFragmentDefinitionCache: ( - rootDir: Uri, - filePath: Uri, - exists: boolean, - ) => Promise; - getSchema: ( - appName?: string, + appName: string, queryHasExtensions?: boolean, ) => Promise; } @@ -192,6 +176,8 @@ export type CompletionItem = CompletionItemType & { deprecationReason?: string | null; type?: GraphQLType; command?: CompletionItemType['command']; + // if label differs from what should be inserted + rawInsert?: string; }; // Below are basically a copy-paste from Nuclide rpc types for definitions. @@ -204,6 +190,7 @@ export type Definition = { name?: string; language?: string; projectRoot?: Uri; + locator?: string; }; // Outline view diff --git a/packages/graphql-language-service/src/utils/validateWithCustomRules.ts b/packages/graphql-language-service/src/utils/validateWithCustomRules.ts index 4f4f2ecac69..00e39a2396a 100644 --- a/packages/graphql-language-service/src/utils/validateWithCustomRules.ts +++ b/packages/graphql-language-service/src/utils/validateWithCustomRules.ts @@ -32,7 +32,9 @@ import { // KnownArgumentNamesOnDirectivesRule, UniqueArgumentNamesRule, UniqueInputFieldNamesRule, - // ProvidedRequiredArgumentsOnDirectivesRule, + UniqueVariableNamesRule, + FragmentsOnCompositeTypesRule, + ProvidedRequiredArgumentsRule, } from 'graphql'; const specifiedSDLRules = [ @@ -49,7 +51,9 @@ const specifiedSDLRules = [ // KnownArgumentNamesOnDirectivesRule, UniqueArgumentNamesRule, UniqueInputFieldNamesRule, - // ProvidedRequiredArgumentsOnDirectivesRule, + UniqueVariableNamesRule, + FragmentsOnCompositeTypesRule, + ProvidedRequiredArgumentsRule, ]; /** diff --git a/packages/monaco-graphql/src/LanguageService.ts b/packages/monaco-graphql/src/LanguageService.ts index 259c699e853..1fff9fd6a91 100644 --- a/packages/monaco-graphql/src/LanguageService.ts +++ b/packages/monaco-graphql/src/LanguageService.ts @@ -15,7 +15,10 @@ import { Source, } from 'graphql'; import picomatch from 'picomatch-browser'; -import type { IPosition } from 'graphql-language-service'; +import type { + AutocompleteSuggestionOptions, + IPosition, +} from 'graphql-language-service'; import { getAutocompleteSuggestions, getDiagnostics, @@ -46,7 +49,7 @@ export class LanguageService { private _externalFragmentDefinitionNodes: FragmentDefinitionNode[] | null = null; private _externalFragmentDefinitionsString: string | null = null; - private _fillLeafsOnComplete?: boolean = false; + private _completionSettings: AutocompleteSuggestionOptions; constructor({ parser, schemas, @@ -54,6 +57,7 @@ export class LanguageService { externalFragmentDefinitions, customValidationRules, fillLeafsOnComplete, + completionSettings, }: GraphQLLanguageConfig) { this._schemaLoader = defaultSchemaLoader; if (schemas) { @@ -63,7 +67,11 @@ export class LanguageService { if (parser) { this._parser = parser; } - this._fillLeafsOnComplete = fillLeafsOnComplete; + this._completionSettings = { + ...completionSettings, + fillLeafsOnComplete: + completionSettings?.fillLeafsOnComplete ?? fillLeafsOnComplete, + }; if (parseOptions) { this._parseOptions = parseOptions; @@ -214,7 +222,7 @@ export class LanguageService { position, undefined, this.getExternalFragmentDefinitions(), - { uri, fillLeafsOnComplete: this._fillLeafsOnComplete }, + { uri, ...this._completionSettings }, ); }; /** diff --git a/packages/monaco-graphql/src/api.ts b/packages/monaco-graphql/src/api.ts index 9dcd2631c51..444950288a5 100644 --- a/packages/monaco-graphql/src/api.ts +++ b/packages/monaco-graphql/src/api.ts @@ -90,7 +90,12 @@ export class MonacoGraphQLAPI { return this._diagnosticSettings; } public get completionSettings(): CompletionSettings { - return this._completionSettings; + return { + ...this._completionSettings, + fillLeafsOnComplete: + this._completionSettings?.__experimental__fillLeafsOnComplete ?? + this._completionSettings?.fillLeafsOnComplete, + }; } public get externalFragmentDefinitions() { return this._externalFragmentDefinitions; diff --git a/packages/monaco-graphql/src/typings/index.ts b/packages/monaco-graphql/src/typings/index.ts index 630907fd843..856a06483bd 100644 --- a/packages/monaco-graphql/src/typings/index.ts +++ b/packages/monaco-graphql/src/typings/index.ts @@ -9,7 +9,10 @@ import { ValidationRule, FragmentDefinitionNode, } from 'graphql'; -import { JSONSchema6 } from 'graphql-language-service'; +import { + AutocompleteSuggestionOptions, + JSONSchema6, +} from 'graphql-language-service'; import type { Options as PrettierConfig } from 'prettier'; export type BaseSchemaConfig = { @@ -127,10 +130,12 @@ export type GraphQLLanguageConfig = { * Custom validation rules following `graphql` `ValidationRule` signature */ customValidationRules?: ValidationRule[]; + completionSettings?: Omit; /** * Should field leafs be automatically expanded & filled on autocomplete? * * NOTE: this can be annoying with required arguments + * @deprecated use `completionSettings.fillLeafsOnComplete` instead */ fillLeafsOnComplete?: boolean; }; @@ -225,24 +230,18 @@ export type DiagnosticSettings = { jsonDiagnosticSettings?: monaco.languages.json.DiagnosticsOptions; }; -export type CompletionSettings = { +export type CompletionSettings = AutocompleteSuggestionOptions & { /** - * EXPERIMENTAL: Automatically fill required leaf nodes recursively - * upon triggering code completion events. - * - * - * - [x] fills required nodes - * - [x] automatically expands relay-style node/edge fields - * - [ ] automatically jumps to first required argument field - * - then, continues to prompt for required argument fields - * - (fixing this will make it non-experimental) - * - when it runs out of arguments, or you choose `{` as a completion option - * that appears when all required arguments are supplied, the argument - * selection closes `)` and the leaf field expands again `{ \n| }` + * @deprecated use fillLeafsOnComplete for parity. still experimental */ __experimental__fillLeafsOnComplete?: boolean; }; +// export type CompletionSettings = { + +// __experimental__fillLeafsOnComplete?: boolean; +// }; + /** * Configuration to initialize the editor with */ diff --git a/packages/monaco-graphql/src/utils.ts b/packages/monaco-graphql/src/utils.ts index add6efd99fa..15de6e4b3e4 100644 --- a/packages/monaco-graphql/src/utils.ts +++ b/packages/monaco-graphql/src/utils.ts @@ -55,10 +55,9 @@ export function toCompletion( ): GraphQLWorkerCompletionItem { const results: GraphQLWorkerCompletionItem = { label: entry.label, - insertText: entry.insertText, - insertTextFormat: entry.insertTextFormat, + insertText: entry?.insertText, sortText: entry.sortText, - filterText: entry.filterText, + filterText: entry?.filterText, documentation: entry.documentation, detail: entry.detail, range: range ? toMonacoRange(range) : undefined, @@ -67,10 +66,16 @@ export function toCompletion( if (entry.insertTextFormat) { results.insertTextFormat = entry.insertTextFormat; } + if (entry.insertTextMode) { + results.insertTextMode = entry.insertTextMode; + } if (entry.command) { results.command = { ...entry.command, id: entry.command.command }; } + if (entry.labelDetails) { + results.labelDetails = entry.labelDetails; + } return results; } diff --git a/packages/monaco-graphql/test/monaco-editor.test.ts b/packages/monaco-graphql/test/monaco-editor.test.ts index c4ef749b517..018c5c05a52 100644 --- a/packages/monaco-graphql/test/monaco-editor.test.ts +++ b/packages/monaco-graphql/test/monaco-editor.test.ts @@ -13,7 +13,7 @@ describe('monaco-editor', () => { // expect(lines[0]).toBe('$ vite build'); // expect(lines[1]).toMatch(' building for production...'); // expect(lines[2]).toBe('transforming...'); - expect(lines[3]).toMatch('✓ 841 modules transformed.'); + expect(lines[3]).toMatch('✓ 843 modules transformed.'); // expect(lines[4]).toBe('rendering chunks...'); // expect(lines[5]).toBe('computing gzip size...'); // expect(lines[6]).toMatch('dist/index.html'); diff --git a/packages/vscode-graphql-execution/package.json b/packages/vscode-graphql-execution/package.json index 73ef2bb2048..038d7f770f1 100644 --- a/packages/vscode-graphql-execution/package.json +++ b/packages/vscode-graphql-execution/package.json @@ -87,7 +87,7 @@ "vsce:package": "yarn compile && vsce package --yarn", "vsce:prepublish": "yarn run vsce:package", "vsce:publish": "vsce publish --yarn", - "open-vsx:publish": "ovsx publish --yarn -i . --pat $OVSX_PAT", + "open-vsx:publish": "ovsx publish $(ls -1 *.vsix | sort -V | tail -n 1) --pat $OVSX_PAT", "release": "yarn run compile && yarn run vsce:publish && yarn run open-vsx:publish" }, "devDependencies": { diff --git a/packages/vscode-graphql-syntax/README.md b/packages/vscode-graphql-syntax/README.md index a4040b662c2..19643a53794 100644 --- a/packages/vscode-graphql-syntax/README.md +++ b/packages/vscode-graphql-syntax/README.md @@ -12,7 +12,6 @@ matching. - PHP (example: [test.php](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test.php)) - Markdown (examples: [test.md](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test.md) & [test-py.md](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test-py.md)) - Scala (example: [test.scala](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test.scala)) -- ruby (example: [test.rb](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test.rb)) You'll want to install this if you do not use `graphql-config`, or want to use the highlighting with other extensions than `vscode-graphql` diff --git a/packages/vscode-graphql-syntax/package.json b/packages/vscode-graphql-syntax/package.json index 849d66fdfd6..9fdf376267f 100644 --- a/packages/vscode-graphql-syntax/package.json +++ b/packages/vscode-graphql-syntax/package.json @@ -63,7 +63,8 @@ "source.vue", "source.svelte", "source.astro", - "text.html.markdown" + "text.html.markdown", + "text.html.derivative" ], "scopeName": "inline.graphql", "path": "./grammars/graphql.js.json", @@ -75,7 +76,8 @@ "injectTo": [ "source.reason", "source.ocaml", - "text.html.markdown" + "text.html.markdown", + "text.html.derivative" ], "scopeName": "inline.graphql.re", "path": "./grammars/graphql.re.json", @@ -86,7 +88,8 @@ { "injectTo": [ "source.rescript", - "text.html.markdown" + "text.html.markdown", + "text.html.derivative" ], "scopeName": "inline.graphql.res", "path": "./grammars/graphql.rescript.json", @@ -96,7 +99,8 @@ }, { "injectTo": [ - "text.html.markdown" + "text.html.markdown", + "text.html.derivative" ], "scopeName": "inline.graphql.markdown.codeblock", "path": "./grammars/graphql.markdown.codeblock.json", @@ -107,7 +111,8 @@ { "injectTo": [ "source.python", - "text.html.markdown" + "text.html.markdown", + "text.html.derivative" ], "scopeName": "inline.graphql.python", "path": "./grammars/graphql.python.json", @@ -118,7 +123,8 @@ { "injectTo": [ "text.html.php", - "text.html.markdown" + "text.html.markdown", + "text.html.derivative" ], "scopeName": "inline.graphql.php", "path": "./grammars/graphql.php.json", @@ -129,7 +135,8 @@ { "injectTo": [ "source.scala", - "text.html.markdown" + "text.html.markdown", + "text.html.derivative" ], "scopeName": "inline.graphql.scala", "path": "./grammars/graphql.scala.json", diff --git a/packages/vscode-graphql-syntax/tests/__fixtures__/test.astro b/packages/vscode-graphql-syntax/tests/__fixtures__/test.astro new file mode 100644 index 00000000000..8698868eec3 --- /dev/null +++ b/packages/vscode-graphql-syntax/tests/__fixtures__/test.astro @@ -0,0 +1,30 @@ +--- +const gql = String.raw; +const response = await fetch( + 'https://swapi-graphql.netlify.app/.netlify/functions/index', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: gql` + query getFilm($id: ID!) { + film(id: $id) { + title + releaseDate + } + } + `, + variables: { + id: 'ZmlsbXM6MQ==', + }, + }), + }, +); + +const json = await response.json(); +const { film } = json.data; +--- + +

Fetching information about Star Wars: A New Hope

+

Title: {film.title}

+

Year: {film.releaseDate}

diff --git a/packages/vscode-graphql-syntax/tests/__snapshots__/js-grammar.spec.ts.snap b/packages/vscode-graphql-syntax/tests/__snapshots__/js-grammar.spec.ts.snap index 12a88876f2a..8cc2f5ead73 100644 --- a/packages/vscode-graphql-syntax/tests/__snapshots__/js-grammar.spec.ts.snap +++ b/packages/vscode-graphql-syntax/tests/__snapshots__/js-grammar.spec.ts.snap @@ -1,5 +1,69 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`inline.graphql grammar > should tokenize a simple astro file 1`] = ` +--- | +const gql = String.raw; | +const response = await fetch( | + 'https://swapi-graphql.netlify.app/.netlify/functions/index', | + { | + method: 'POST', | + headers: { 'Content-Type': 'application/json' }, | + body: JSON.stringify({ | + query: | + | +gql | entity.name.function.tagged-template.js +\` | punctuation.definition.string.template.begin.js + | meta.embedded.block.graphql +query | meta.embedded.block.graphql keyword.operation.graphql + | meta.embedded.block.graphql +getFilm | meta.embedded.block.graphql entity.name.function.graphql +( | meta.embedded.block.graphql meta.brace.round.graphql +$id | meta.embedded.block.graphql meta.variables.graphql variable.parameter.graphql +: | meta.embedded.block.graphql meta.variables.graphql punctuation.colon.graphql + | meta.embedded.block.graphql meta.variables.graphql +ID | meta.embedded.block.graphql meta.variables.graphql support.type.builtin.graphql +! | meta.embedded.block.graphql meta.variables.graphql keyword.operator.nulltype.graphql +) | meta.embedded.block.graphql meta.brace.round.graphql + | meta.embedded.block.graphql meta.selectionset.graphql +{ | meta.embedded.block.graphql meta.selectionset.graphql punctuation.operation.graphql + | meta.embedded.block.graphql meta.selectionset.graphql +film | meta.embedded.block.graphql meta.selectionset.graphql variable.graphql +( | meta.embedded.block.graphql meta.selectionset.graphql meta.arguments.graphql meta.brace.round.directive.graphql +id | meta.embedded.block.graphql meta.selectionset.graphql meta.arguments.graphql variable.parameter.graphql +: | meta.embedded.block.graphql meta.selectionset.graphql meta.arguments.graphql punctuation.colon.graphql + | meta.embedded.block.graphql meta.selectionset.graphql meta.arguments.graphql +$id | meta.embedded.block.graphql meta.selectionset.graphql meta.arguments.graphql variable.graphql +) | meta.embedded.block.graphql meta.selectionset.graphql meta.arguments.graphql meta.brace.round.directive.graphql + | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql +{ | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql punctuation.operation.graphql + | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql +title | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql variable.graphql + | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql +releaseDate | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql variable.graphql + | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql +} | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql punctuation.operation.graphql + | meta.embedded.block.graphql meta.selectionset.graphql +} | meta.embedded.block.graphql meta.selectionset.graphql punctuation.operation.graphql + | meta.embedded.block.graphql +\` | punctuation.definition.string.template.end.js +, | + variables: { | + id: 'ZmlsbXM6MQ==', | + }, | + }), | + }, | +); | + | +const json = await response.json(); | +const { film } = json.data; | +--- | + | +

Fetching information about Star Wars: A New Hope

| +

Title: {film.title}

| +

Year: {film.releaseDate}

| + | +`; + exports[`inline.graphql grammar > should tokenize a simple ecmascript file 1`] = ` /* eslint-disable */ | /* prettier-ignore */ | diff --git a/packages/vscode-graphql-syntax/tests/js-grammar.spec.ts b/packages/vscode-graphql-syntax/tests/js-grammar.spec.ts index 91332e32551..786018e4562 100644 --- a/packages/vscode-graphql-syntax/tests/js-grammar.spec.ts +++ b/packages/vscode-graphql-syntax/tests/js-grammar.spec.ts @@ -23,4 +23,8 @@ describe('inline.graphql grammar', () => { const result = await tokenizeFile('__fixtures__/test.svelte', scope); expect(result).toMatchSnapshot(); }); + it('should tokenize a simple astro file', async () => { + const result = await tokenizeFile('__fixtures__/test.astro', scope); + expect(result).toMatchSnapshot(); + }); }); diff --git a/packages/vscode-graphql/README.md b/packages/vscode-graphql/README.md index 6cad22385da..3666f171184 100644 --- a/packages/vscode-graphql/README.md +++ b/packages/vscode-graphql/README.md @@ -7,28 +7,9 @@ Ecosystem with VSCode for an awesome developer experience. ![](https://camo.githubusercontent.com/97dc1080d5e6883c4eec3eaa6b7d0f29802e6b4b/687474703a2f2f672e7265636f726469742e636f2f497379504655484e5a342e676966) -### General features - -> _Operation Execution will be re-introduced in a new extension_ - -- Load the extension on detecting `graphql-config file` at root level or in a - parent level directory -- Load the extension in `.graphql`, `.gql files` -- Load the extension detecting `gql` tag in js, ts, jsx, tsx, vue files -- Load the extension inside `gql`/`graphql` fenced code blocks in markdown files -- NO LONGER SUPPORTED - execute query/mutation/subscription operations, embedded - or in graphql files - we will be recommending other extensions for this. -- pre-load schema and document definitions -- Support [`graphql-config`](https://graphql-config.com/) files with one project - and multiple projects (multi-workspace roots with multiple graphql config - files not yet supported) -- the language service re-starts on saved changes to vscode settings and/or - graphql config! - -### `.graphql`, `.gql` file extension support +### `.graphql`, `.gql` file extension support and `gql`/`graphql` tagged template literal support for tsx, jsx, ts, js -- syntax highlighting (type, query, mutation, interface, union, enum, scalar, - fragments, directives) +- syntax highlighting (provided by `vscode-graphql-syntax`) - autocomplete suggestions - validation against schema - snippets (interface, type, input, enum, union) @@ -36,59 +17,51 @@ Ecosystem with VSCode for an awesome developer experience. - go to definition support (input, enum, type) - outline support -### `gql`/`graphql` tagged template literal support for tsx, jsx, ts, js +## Getting Started -- syntax highlighting (type, query, mutation, interface, union, enum, scalar, - fragments, directives) -- autocomplete suggestions -- validation against schema -- snippets -- hover support -- go to definition for fragments and input types -- outline support +> **This extension requires a graphql-config file**. -## Usage +To support language features like completion and "go-to definition" across multiple files, +please include `documents` in the `graphql-config` file default or per-project -**This extension requires a graphql-config file**. +### Simplest Config Example -Install the -[VSCode GraphQL Extension](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql). - -(Watchman is no longer required, you can uninstall it now) +```yaml +# .graphqlrc.yml or graphql.config.yml +schema: 'schema.graphql' +documents: 'src/**/*.{graphql,js,ts,jsx,tsx}' +``` -As of `vscode-graphql@0.3.0` we support `graphql-config@3`. You can read more -about that [here](https://www.graphql-config.com/docs/user/user-usage). Because -it now uses `cosmiconfig` there are plenty of new options for loading config -files: +`package.json`: -``` -graphql.config.json -graphql.config.js -graphql.config.yaml -graphql.config.yml -.graphqlrc (YAML or JSON) -.graphqlrc.json -.graphqlrc.yaml -.graphqlrc.yml -.graphqlrc.js -graphql property in package.json +```json +"graphql": { + "schema": "https://localhost:3001", + "documents": "**/*.{graphql,js,ts,jsx,tsx}" +}, ``` -the file needs to be placed at the project root by default, but you can -configure paths per project. see the FAQ below for details. +```ts +// .graphqlrc.ts or graphql.config.ts +export default { + schema: 'schema.graphql', + documents: '**/*.{graphql,js,ts,jsx,tsx}', +}; +``` -Previous versions of this extension support `graphql-config@2` format, which -follows -[legacy configuration patterns](https://github.com/kamilkisiela/graphql-config/tree/legacy#usage) +same for .json, .toml, etc -If you need legacy support for `.graphqlconfig` files or older graphql-config -formats, see [this FAQ answer](#legacy). If you are missing legacy -`graphql-config` features, please consult -[the `graphql-config` repository](https://github.com/kamilkisiela/graphql-config). +## Additional Features -To support language features like "go-to definition" across multiple files, -please include `documents` key in the `graphql-config` file default or -per-project (this was `include` in 2.0). +- Loads the LSP server upon detecting a `graphql-config` file at root level or in a + parent level directory, or a `package.json` file with `graphql` config +- Loads `.graphql`, `.gql` files, and detects `gql`, `graphql` tags and `/** GraphQL */` and `#graphql` comments in js, ts, jsx, tsx, vue files +- pre-load schema and fragment definitions +- Support [`graphql-config`](https://graphql-config.com/) files with one project + and multiple projects (multi-workspace roots with multiple graphql config + files not yet supported) +- the language service re-starts on saved changes to vscode settings and/or + graphql config! ## Configuration Examples @@ -97,19 +70,22 @@ For more help with configuring the language server, is the source of truth for all settings used by all editors which use the language server. -### Simple Example +This includes LSP settings provided by extensions like `vscode-graphql`, nvim, etc. -```yaml -# .graphqlrc.yml -schema: 'schema.graphql' -documents: 'src/**/*.{graphql,js,ts,jsx,tsx}' -``` +There are a number of configurations that can be provided from both editor settings or the graphql config file, and the editor setting takes precedence, to allow users to override their graphql config file settings in a user context as needed. + +The [`graphql-config`](https://graphql-config.com/) docs are also very helpful for the config file. ### Advanced Example -```js -// graphql.config.js -module.exports = { +Multi-project can be used for both local files, URL defined schema, or both + +```ts +import dotenv from 'dotenv'; +dotenv.config(); + +// .graphqlrc.ts or graphql.config.ts +export default { projects: { app: { schema: ['src/schema.graphql', 'directives.graphql'], @@ -119,15 +95,15 @@ module.exports = { schema: 'src/generated/db.graphql', documents: ['src/db/**/*.graphql', 'my/fragments.graphql'], extensions: { - codegen: [ - { - generator: 'graphql-binding', - language: 'typescript', - output: { - binding: 'src/generated/db.ts', + // for use with `vscode-graphql-execution`, for example: + endpoints: { + default: { + url: 'https://localhost:3001/graphql/', + headers: { + Authorization: `Bearer ${process.env.API_TOKEN}`, }, }, - ], + }, }, }, }, @@ -139,66 +115,9 @@ is also valid. ## Frequently Asked Questions - - -### I can't load `.graphqlconfig` files anymore - -> Note: this option has been set to be enabled by default, however -> `graphql-config` maintainers do not want to continue to support the legacy -> format (mostly kept for companies where intellij users are stuck on the old -> config format), so please migrate to the new `graphql-config` format as soon -> as possible! - -If you need to use a legacy config file, then you just need to enable legacy -mode for `graphql-config`: - -```json -"graphql-config.load.legacy": true -``` - -### Go to definition is not working for my URL - -You can try the new experimental `cacheSchemaFileForLookup` option. NOTE: this -will disable all definition lookup for local SDL graphql schema files, and -_only_ perform lookup of the result an SDL result of `graphql-config` -`getSchema()` - -To enable, add this to your settings: - -```json -"vscode-graphql.cacheSchemaFileForLookup": true, -``` - -you can also use graphql config if you need to mix and match these settings: - -```yml -schema: 'http://myschema.com/graphql' -extensions: - languageService: - cacheSchemaFileForLookup: true -projects: - project1: - schema: 'project1/schema/schema.graphql' - documents: 'project1/queries/**/*.{graphql,tsx,jsx,ts,js}' - extensions: - languageService: - cacheSchemaFileForLookup: false - - project2: - schema: 'https://api.spacex.land/graphql/' - documents: 'project2/queries.graphql' - extensions: - endpoints: - default: - url: 'https://api.spacex.land/graphql/' - languageService: - # Do project configs inherit parent config? - cacheSchemaFileForLookup: true -``` - ### The extension fails with errors about duplicate types -Make sure that you aren't including schema files in the `documents` blob +Your object types must be unique per project (as they must be unique per schema), and your fragment names must also be unique per project. ### The extension fails with errors about missing scalars, directives, etc @@ -232,6 +151,7 @@ You can search a folder for any of the matching config file names listed above: ```json "graphql-config.load.rootDir": "./config" +"graphql-config.envFilePath": "./config/.dev.env" ``` Or a specific filepath: @@ -253,39 +173,15 @@ which would search for `./config/.acmerc`, `.config/.acmerc.js`, If you have multiple projects, you need to define one top-level config that defines all project configs using `projects` -### How do I highlight an embedded graphql string? - -If you aren't using a template tag function such as `gql` or `graphql`, and just -want to use a plain string, you can use an inline `#graphql` comment: +### How do I enable language features for an embedded graphql string? -```ts -const myQuery = `#graphql - query { - something - } -`; -``` - -or - -```ts -const myQuery = - /* GraphQL */ - - ` - query { - something - } - `; -``` +Please refer to the `vscode-graphql-syntax` reference files ([js](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test.js),[ts](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test.ts),[svelte](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test.svelte),[vue](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test.vue)) to learn our template tag, comment and other graphql delimiter patterns for the file types that the language server supports. The syntax highlighter currently supports more languages than the language server. If you notice any places where one or the other doesn't work, please report it! ## Known Issues -- the output channel occasionally shows "definition not found" when you first - start the language service, but once the definition cache is built for each - project, definition lookup will work. so if a "peek definition" fails when you - first start the editor or when you first install the extension, just try the - definition lookup again. +- the locally generated schema file for definition lookup currently does not re-generate on schema changes. this will be fixed soon. +- multi-root workspaces support will be added soon as well. +- some graphql-config options aren't always honored, this will also be fixed soon ## Attribution @@ -312,7 +208,7 @@ This plugin uses the ### Contributing back to this project This repository is managed by EasyCLA. Project participants must sign the free -([GraphQL Specification Membership agreement](https://preview-spec-membership.graphql.org) +([GraphQL Specification Membership agreement](https://preview-spec-membership.graphql.org)) before making a contribution. You only need to do this one time, and it can be signed by [individual contributors](http://individual-spec-membership.graphql.org/) or diff --git a/packages/vscode-graphql/package.json b/packages/vscode-graphql/package.json index 767b58a127f..876b65103ef 100644 --- a/packages/vscode-graphql/package.json +++ b/packages/vscode-graphql/package.json @@ -1,6 +1,6 @@ { "name": "vscode-graphql", - "version": "0.9.3", + "version": "0.10.2", "private": true, "license": "MIT", "displayName": "GraphQL: Language Feature Support", @@ -85,13 +85,13 @@ "null" ], "default": false, - "description": "Enable debug logs" + "description": "Enable debug logs and node debugger for client" }, "vscode-graphql.cacheSchemaFileForLookup": { "type": [ "boolean" ], - "description": "Use a cached file output of your graphql-config schema result for definition lookups, symbols, outline, etc. Disabled by default." + "description": "Use a cached file output of your graphql-config schema result for definition lookups, symbols, outline, etc. Enabled by default when one or more schema entry is not a local file with SDL in it. Disable if you want to use SDL with a generated schema." }, "vscode-graphql.largeFileThreshold": { "type": [ @@ -107,40 +107,47 @@ "description": "Fail the request on invalid certificate", "default": true }, - "graphql-config.load.rootDir": { + "vscode-graphql.schemaCacheTTL": { "type": [ - "string" + "number" ], - "description": "Base dir for graphql config loadConfig()" + "description": "Schema cache ttl in milliseconds - the interval before requesting a fresh schema when caching the local schema file is enabled. Defaults to 30000 (30 seconds).", + "default": 30000 }, - "graphql-config.load.filePath": { + "graphql-config.load.rootDir": { "type": [ "string" ], - "description": "filePath for graphql config loadConfig()", - "default": null + "description": "Base dir for graphql config loadConfig(), to look for config files or package.json" }, - "graphql-config.load.legacy": { + "graphql-config.load.filePath": { "type": [ - "boolean" + "string" ], - "description": "legacy mode for graphql config v2 config", + "description": "exact filePath for a `graphql-config` file `loadConfig()`", "default": null }, "graphql-config.load.configName": { "type": [ "string" ], - "description": "optional .config.js instead of default `graphql`", + "description": "optional .config.{js,ts,toml,yaml,json} & rc* instead of default `graphql`", "default": null }, - "graphql-config.dotEnvPath": { + "graphql-config.load.legacy": { "type": [ - "string" + "boolean" ], - "description": "optional .env load path, if not the default", + "description": "legacy mode for graphql config v2 config", "default": null } + }, + "graphql-config.dotEnvPath": { + "type": [ + "string" + ], + "description": "optional .env load file path, if not the default. specify a relative path to the .env file to be loaded by dotenv module. you can also import dotenv in the config file.", + "default": null } }, "commands": [ @@ -161,7 +168,7 @@ "vsce:package": "vsce package --yarn", "env:source": "export $(cat .envrc | xargs)", "vsce:publish": "vsce publish --yarn", - "open-vsx:publish": "ovsx publish --extensionFile $(ls -1 *.vsix | tail -n 1) --pat $OVSX_PAT", + "open-vsx:publish": "ovsx publish $(ls -1 *.vsix | sort -V | tail -n 1) --pat $OVSX_PAT", "release": "npm run vsce:publish && npm run open-vsx:publish" }, "devDependencies": { diff --git a/custom-words.txt b/resources/custom-words.txt similarity index 98% rename from custom-words.txt rename to resources/custom-words.txt index 297692fd04b..2bbbb473dee 100644 --- a/custom-words.txt +++ b/resources/custom-words.txt @@ -1,235 +1,228 @@ +// short for "developer experience": +// short for "maintainers": +// short for "platform as a service": +// these pop up when writing "GraphQL___" // (user-)names -arthurgeron -Divyendu -Leko -LekoArts +// abbreviations +// companies and organizations +// cspell en-us/en-gb edgecases? +// fonts +// identifiers used in code and configs +// locations +// other +// other languages +// packages and tools +// phonetic notation +ˈɡrafək acao +acmerc aivazis akshat alexey alok +amfoss +architecting +argparse arminio +arrayish +arthurgeron asiandrummer +astro +astrojs aumy +Autopurge +behaviour benjie +binti +blockstring bobbybobby borggreve bram -cshaver -dhanani -divy -divyenduz -dotan -dotansimha -gillam -goldshtein -goncharov -graphi -harshith -heyenbrock -hurrell -hyohyeon -imolorhe -jeong -jonathanawesome -kumar -leebyron -lostplan -nauroze -nishchit -nowdoc -orta -pabbati -pratap -ravikoti -rikki -rodionov -rohit -saihaj -saihajpreet -scheer -schulte -schuster -sgrove -simha -stonexer -suchanek -tanay -tanaypratap -therox -thomasheyenbrock -timsuchanek -urigo -wincent -yoshiakis - -// packages and tools -argparse -astro -astrojs +browserslistrc +calar +chainable changesets clsx +codebases +codegen codemirror codesandbox +codespaces +codicon +colmena combobox +cshaver +dedenting delivr +devx +dhanani +dirpath +divy +Divyendu +divyenduz dompurify +dotan +dotansimha +edcore +envrc esbuild execa -GraphiQL -headlessui -inno -intellij -jsdelivr -lezer -manypkg -modulemap -mockfs -meros -nullthrows -onig -ovsx -picomatch -pnpm -snyk -sonarjs -svgr -typedoc -vite -vitest -vitejs -vsix -wonka -urql -tsup -usememo - -// identifiers used in code and configs -acmerc -binti -blockstring -browserslistrc -calar -chainable -codegen -dirpath -envrc +exfiltrate filesfor +fira +firecamp flowtests foldgutter foldmarker +gdezerno ghapi +gillam +givatayim +goldshtein +goncharov +grafbase +graphi +graphile +GraphiQL graphqlconfig graphqlrc graphqls +harshith +headlessui +heyenbrock +hola htmling +hurrell +hyohyeon +imolorhe +inno +intellij invalidchar +jammu +jeong +jiti +jonathanawesome +jsdelivr +kumar languageservice +leebyron +Leko +LekoArts +lezer linenumber linenumbers linkify listbox listvalues +lostplan +maint +manypkg marko matchingbracket +medellín +menlo +meros middlewares +mockfs +modulemap +nauroze newhope nextjs +nishchit nocheck nocursor nonmatchingbracket +novvum +nowdoc nrtbf +nullthrows nulltype nvim objectvalues +onig +ooops orche +orta outdir outlineable +ovsx +paas +pabbati +picomatch +pieas +pnpm postbuild +pratap prebuild +proto +qlapi +qlid +qlide quasis ractive +randomthing +ravikoti resi resizer +rikki +roadmap +roboto +rodionov +rohit runmode +runtimes +saihaj +saihajpreet +scheer +schulte +schuster searchcursor selectionset sfc's +sgrove +simha singleline +snyk socker +sonarjs +sorare squirrelly +stonexer streamable subword +suchanek +svgr +tanay +tanaypratap testid testonly +therox +thomasheyenbrock +timsuchanek +tokenizes +tsup +typeahead +typeaheads +typedoc unfocus unnormalized +unparsable unsubscribable +urigo +urql +usememo vash -websockets - -// fonts -fira -menlo -roboto - -// locations -givatayim -jammu -medellín +vite +vitejs +vitest vizag - -// companies and organizations -amfoss -colmena -firecamp -gdezerno -grafbase -graphile -novvum -pieas -sorare - -// other languages -hola +vsix +websockets +wincent +wonka +yoshiakis zdravo Здорово أهلاً سلام हेलो - -// phonetic notation -ˈɡrafək - -// abbreviations -// short for "developer experience": -devx -// short for "maintainers": -maint -// short for "platform as a service": -paas -// these pop up when writing "GraphQL___" -qlapi -qlid -qlide - -// cspell en-us/en-gb edgecases? -behaviour - -// other -architecting -codebases -codespaces -dedenting -exfiltrate -ooops -proto -roadmap -runtimes -typeahead -typeaheads -unparsable -randomthing -codicon -edcore -tokenizes diff --git a/resources/prettierignore b/resources/prettierignore new file mode 100644 index 00000000000..bd6c60530a5 --- /dev/null +++ b/resources/prettierignore @@ -0,0 +1,3 @@ +# this file contains delicate whitespace character assertions that i could not get prettier to ignore +# if removed it will cause the tests to fail +../packages/graphql-language-service-server/src/__tests__/parseDocument.test.ts diff --git a/yarn.lock b/yarn.lock index 91b899addb1..015c4a808f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -51,10 +51,10 @@ resolved "https://registry.yarnpkg.com/@arthurgeron/eslint-plugin-react-usememo/-/eslint-plugin-react-usememo-1.1.4.tgz#7c92ef49813191f5af18339242b60f4beddabc86" integrity sha512-OIjOhplz6MT+HgJjKZT1SDGzhofSRZaYfNBc7yRl/eeuh2VfUlRQP9ulReBLmfwuQWyRLr0wcdazQNKq35MaEw== -"@astrojs/compiler@^2.5.0": - version "2.5.0" - resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-2.5.0.tgz#dba7a7a936aed98089b93505dda1c1011ba82746" - integrity sha512-ZDluNgMIJT+z+HJcZ6QEJ/KqaFkTkrb+Za6c6VZs8G/nb1LBErL14/iU5EVJ9yu25i4QCLweuBJ3m5df34gZJg== +"@astrojs/compiler@^2.8.0": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-2.8.0.tgz#acbe2abbf640c238cbfe6c9d886a4d0c6921f172" + integrity sha512-yrpD1WRGqsJwANaDIdtHo+YVjvIOFAjC83lu5qENIgrafwZcJgSXDuwVMXOgok4tFzpeKLsFQ6c3FoUdloLWBQ== "@babel/cli@^7.21.0": version "7.21.0" @@ -108,6 +108,14 @@ "@babel/highlight" "^7.23.4" chalk "^2.4.2" +"@babel/code-frame@^7.24.2": + version "7.24.2" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.2.tgz#718b4b19841809a58b29b68cde80bc5e1aa6d9ae" + integrity sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ== + dependencies: + "@babel/highlight" "^7.24.2" + picocolors "^1.0.0" + "@babel/compat-data@^7.17.7": version "7.17.7" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.17.7.tgz#078d8b833fbbcc95286613be8c716cef2b519fa2" @@ -308,6 +316,16 @@ "@jridgewell/trace-mapping" "^0.3.17" jsesc "^2.5.1" +"@babel/generator@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.5.tgz#e5afc068f932f05616b66713e28d0f04e99daeb3" + integrity sha512-x32i4hEXvr+iI0NEoEfDKzlemF8AmtOP8CcrRaEcpzysWuoEb1KknpcvMsHKPONoKZiDuItklgWhB18xEhr9PA== + dependencies: + "@babel/types" "^7.24.5" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^2.5.1" + "@babel/generator@^7.7.2": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.9.tgz#68337e9ea8044d6ddc690fb29acae39359cca0a5" @@ -822,6 +840,13 @@ dependencies: "@babel/types" "^7.22.5" +"@babel/helper-split-export-declaration@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz#b9a67f06a46b0b339323617c8c6213b9055a78b6" + integrity sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q== + dependencies: + "@babel/types" "^7.24.5" + "@babel/helper-string-parser@^7.19.4": version "7.19.4" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz#38d3acb654b4701a9b77fb0615a96f775c3a9e63" @@ -837,6 +862,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83" integrity sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ== +"@babel/helper-string-parser@^7.24.1": + version "7.24.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz#f99c36d3593db9540705d0739a1f10b5e20c696e" + integrity sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ== + "@babel/helper-validator-identifier@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" @@ -867,6 +897,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz#9544ef6a33999343c8740fa51350f30eeaaaf193" integrity sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ== +"@babel/helper-validator-identifier@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz#918b1a7fa23056603506370089bd990d8720db62" + integrity sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA== + "@babel/helper-validator-option@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz#b203ce62ce5fe153899b617c08957de860de4d23" @@ -1001,6 +1036,16 @@ chalk "^2.4.2" js-tokens "^4.0.0" +"@babel/highlight@^7.24.2": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.5.tgz#bc0613f98e1dd0720e99b2a9ee3760194a704b6e" + integrity sha512-8lLmua6AVh/8SLJRRVD6V8p73Hir9w5mJrhE+IPpILG31KKlI9iz5zmBYKcWPS59qSfgP9RaSBQSHHE81WKuEw== + dependencies: + "@babel/helper-validator-identifier" "^7.24.5" + chalk "^2.4.2" + js-tokens "^4.0.0" + picocolors "^1.0.0" + "@babel/parser@^7.1.0", "@babel/parser@^7.12.13": version "7.13.13" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.13.tgz#42f03862f4aed50461e543270916b47dd501f0df" @@ -1036,6 +1081,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.7.tgz#df8cf085ce92ddbdbf668a7f186ce848c9036cae" integrity sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q== +"@babel/parser@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.5.tgz#4a4d5ab4315579e5398a82dcf636ca80c3392790" + integrity sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg== + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2" @@ -2388,18 +2438,18 @@ "@babel/types" "^7.12.13" "@babel/traverse@^7.16.8", "@babel/traverse@^7.17.3", "@babel/traverse@^7.20.5", "@babel/traverse@^7.20.7", "@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.22.5", "@babel/traverse@^7.22.6", "@babel/traverse@^7.22.8", "@babel/traverse@^7.23.2", "@babel/traverse@^7.23.7", "@babel/traverse@^7.7.2": - version "7.23.7" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.7.tgz#9a7bf285c928cb99b5ead19c3b1ce5b310c9c305" - integrity sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg== + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.5.tgz#972aa0bc45f16983bf64aa1f877b2dd0eea7e6f8" + integrity sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA== dependencies: - "@babel/code-frame" "^7.23.5" - "@babel/generator" "^7.23.6" + "@babel/code-frame" "^7.24.2" + "@babel/generator" "^7.24.5" "@babel/helper-environment-visitor" "^7.22.20" "@babel/helper-function-name" "^7.23.0" "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/parser" "^7.23.6" - "@babel/types" "^7.23.6" + "@babel/helper-split-export-declaration" "^7.24.5" + "@babel/parser" "^7.24.5" + "@babel/types" "^7.24.5" debug "^4.3.1" globals "^11.1.0" @@ -2455,6 +2505,15 @@ "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" +"@babel/types@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.5.tgz#7661930afc638a5383eb0c4aee59b74f38db84d7" + integrity sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ== + dependencies: + "@babel/helper-string-parser" "^7.24.1" + "@babel/helper-validator-identifier" "^7.24.5" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -4047,6 +4106,15 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/trace-mapping" "^0.3.9" +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + "@jridgewell/resolve-uri@3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" @@ -4057,6 +4125,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz#68eb521368db76d040a6315cdb24bf2483037b9c" integrity sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew== +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + "@jridgewell/set-array@^1.0.0": version "1.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.1.tgz#36a6acc93987adcf0ba50c66908bd0b70de8afea" @@ -4067,6 +4140,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + "@jridgewell/source-map@^0.3.2": version "0.3.2" resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb" @@ -4122,6 +4200,14 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@jridgewell/trace-mapping@^0.3.9": version "0.3.14" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed" @@ -5453,6 +5539,13 @@ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-9.1.1.tgz#e7c4f1001eefa4b8afbd1eee27a237fee3bf29c4" integrity sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw== +"@types/mock-fs@^4.13.4": + version "4.13.4" + resolved "https://registry.yarnpkg.com/@types/mock-fs/-/mock-fs-4.13.4.tgz#e73edb4b4889d44d23f1ea02d6eebe50aa30b09a" + integrity sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg== + dependencies: + "@types/node" "*" + "@types/ms@*": version "0.7.31" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" @@ -6051,14 +6144,15 @@ pretty-format "^27.5.1" "@vscode/vsce@^2.19.0", "@vscode/vsce@^2.23.0": - version "2.24.0" - resolved "https://registry.yarnpkg.com/@vscode/vsce/-/vsce-2.24.0.tgz#7f835b9fdd5bfedcecd62a6c4d684841a74974d4" - integrity sha512-p6CIXpH5HXDqmUkgFXvIKTjZpZxy/uDx4d/UsfhS9vQUun43KDNUbYeZocyAHgqcJlPEurgArHz9te1PPiqPyA== + version "2.23.0" + resolved "https://registry.yarnpkg.com/@vscode/vsce/-/vsce-2.23.0.tgz#280ce82356c59efda97d3ba14bcdd9e3e22ddb7f" + integrity sha512-Wf9yN8feZf4XmUW/erXyKQvCL577u72AQv4AI4Cwt5o5NyE49C5mpfw3pN78BJYYG3qnSIxwRo7JPvEurkQuNA== dependencies: azure-devops-node-api "^11.0.1" chalk "^2.4.2" cheerio "^1.0.0-rc.9" commander "^6.2.1" + find-yarn-workspace-root "^2.0.0" glob "^7.0.6" hosted-git-info "^4.0.2" jsonc-parser "^3.2.0" @@ -6854,10 +6948,10 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== -astrojs-compiler-sync@^0.3.5: - version "0.3.5" - resolved "https://registry.yarnpkg.com/astrojs-compiler-sync/-/astrojs-compiler-sync-0.3.5.tgz#bdfeb511b30c908104375cdfe04545f56c8ade3f" - integrity sha512-y420rhIIJ2HHDkYeqKArBHSdJNIIGMztLH90KGIX3zjcJyt/cr9Z2wYA8CP5J1w6KE7xqMh0DAkhfjhNDpQb2Q== +astrojs-compiler-sync@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/astrojs-compiler-sync/-/astrojs-compiler-sync-1.0.0.tgz#9991bd4cd09b60f41687e86b137171012722dbf3" + integrity sha512-IM6FxpMoBxkGGdKppkFHNQIC9Wge7jspG2MIJff8DOhG41USNJLxJfxRm7wnkTKWlYK5Y1YFFNYr2vUUKkI8sw== dependencies: synckit "^0.9.0" @@ -7531,30 +7625,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001317, caniuse-lite@^1.0.30001328: - version "1.0.30001450" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001450.tgz" - integrity sha512-qMBmvmQmFXaSxexkjjfMvD5rnDL0+m+dUMZKoDYsGG8iZN29RuYh9eRoMvKsT6uMAWlyUUGDEQGJJYjzCIO9ew== - -caniuse-lite@^1.0.30001406: - version "1.0.30001507" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001507.tgz#fae53f6286e7564783eadea9b447819410a59534" - integrity sha512-SFpUDoSLCaE5XYL2jfqe9ova/pbQHEmbheDf5r4diNwbAgR3qxM9NQtfsiSscjqoya5K7kFcHPUQ+VsUkIJR4A== - -caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001449: - version "1.0.30001457" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001457.tgz#6af34bb5d720074e2099432aa522c21555a18301" - integrity sha512-SDIV6bgE1aVbK6XyxdURbUE89zY7+k1BBBaOwYwkNCglXlel/E7mELiHC64HQ+W0xSKlqWhV9Wh7iHxUjMs4fA== - -caniuse-lite@^1.0.30001517: - version "1.0.30001518" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001518.tgz#b3ca93904cb4699c01218246c4d77a71dbe97150" - integrity sha512-rup09/e3I0BKjncL+FesTayKtPrdwKhUufQFd3riFw1hHg8JmIFoInYfB102cFcY/pPgGmdyl/iy+jgiDi2vdA== - -caniuse-lite@^1.0.30001565: - version "1.0.30001574" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz#fb4f1359c77f6af942510493672e1ec7ec80230c" - integrity sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001317, caniuse-lite@^1.0.30001328, caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001565: + version "1.0.30001588" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001588.tgz" + integrity sha512-+hVY9jE44uKLkH0SrUTqxjxqNTOWHsbnQDIKjwkZ3lNTzUUVdBLBGXtj/q5Mp5u98r3droaZAewQuEDzjQdZlQ== capital-case@^1.0.4: version "1.0.4" @@ -10727,9 +10801,9 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0: integrity sha512-aExlJShTV4qOUOL7yF1U5tvLCB0xQuudbf6toyYA0E/acBNw71mvjFTnLaRp50aQaYocMR0a/RMMBIHeZnGyjQ== follow-redirects@^1.13.2: - version "1.15.6" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" - integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + version "1.15.5" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" + integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== follow-redirects@^1.14.6: version "1.15.2" @@ -13849,6 +13923,11 @@ lowercase-keys@^2.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== +lru-cache@^10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" + integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== + lru-cache@^4.0.1: version "4.1.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"