diff --git a/.changeset/famous-clocks-change.md b/.changeset/famous-clocks-change.md new file mode 100644 index 00000000..b8637db3 --- /dev/null +++ b/.changeset/famous-clocks-change.md @@ -0,0 +1,5 @@ +--- +"vscode-apollo": patch +--- + +Fix a bug where when rapidly changing multiple files some of the changes might have gotten lost. diff --git a/.changeset/plenty-radios-juggle.md b/.changeset/plenty-radios-juggle.md new file mode 100644 index 00000000..73187001 --- /dev/null +++ b/.changeset/plenty-radios-juggle.md @@ -0,0 +1,12 @@ +--- +"vscode-apollo": patch +--- + +Fixed a bug where hints on the 0-th line of an embedded GraphQL document were offset incorrectly. + + +E.g. in +```js +const veryLongVariableName = gql`type Foo { baaaaaar: String }` +``` +the hover on `String` would only appear when hovering characters left of it. diff --git a/.vscode/launch.json b/.vscode/launch.json index 4dab747c..5987f212 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,10 @@ "${workspaceFolder}/sampleWorkspace/sampleWorkspace.code-workspace" ], "sourceMaps": true, - "env": { "APOLLO_ENGINE_ENDPOINT": "http://localhost:7096/apollo" }, + "env": { + "APOLLO_ENGINE_ENDPOINT": "http://localhost:7096/apollo", + "APOLLO_FEATURE_FLAGS": "rover" + }, "outFiles": ["${workspaceRoot}/lib/**/*.js"] }, { diff --git a/package-lock.json b/package-lock.json index fc58d5de..41bc37b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,14 @@ "@wry/equality": "0.5.7", "cosmiconfig": "9.0.0", "dotenv": "16.4.5", + "fractional-indexing": "2.1.0", "glob": "11.0.0", "graphql": "16.9.0", "graphql-language-service": "5.2.2", "graphql-tag": "2.12.6", "lodash.debounce": "4.0.8", "lodash.merge": "4.6.2", + "lodash.throttle": "4.1.1", "lz-string": "1.5.0", "minimatch": "10.0.1", "moment": "2.30.1", @@ -29,6 +31,7 @@ "vscode-languageserver": "9.0.1", "vscode-languageserver-textdocument": "1.0.12", "vscode-uri": "3.0.8", + "which": "4.0.0", "zod": "3.23.8", "zod-validation-error": "3.3.1" }, @@ -40,6 +43,7 @@ "@types/jest": "29.5.12", "@types/lodash.debounce": "4.0.9", "@types/lodash.merge": "4.6.9", + "@types/lodash.throttle": "^4.1.9", "@types/node": "20.14.10", "@types/vscode": "1.90.0", "@typescript-eslint/eslint-plugin": "6.9.1", @@ -4603,6 +4607,16 @@ "@types/lodash": "*" } }, + "node_modules/@types/lodash.throttle": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz", + "integrity": "sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/minimist": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.3.tgz", @@ -8015,6 +8029,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fractional-indexing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-2.1.0.tgz", + "integrity": "sha512-4tIVui+5dxsXe/BG7D9EzgNIDK9fEoBzjvAf9gFfxFDPo2LsPSjfFKB2QmtcvmioD2IlshtcJFXGEcDPTG6R/A==", + "license": "CC0-1.0", + "engines": { + "node": ">=12" + } + }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -10616,6 +10639,12 @@ "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", "dev": true }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -11548,6 +11577,19 @@ "node": ">=4" } }, + "node_modules/npm-run-all/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -13043,6 +13085,19 @@ "node": ">=0.10.0" } }, + "node_modules/spawndamnit/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/spawndamnit/node_modules/yallist": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", @@ -14202,16 +14257,18 @@ } }, "node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", "license": "ISC", "dependencies": { - "isexe": "^2.0.0" + "isexe": "^3.1.1" }, "bin": { - "which": "bin/which" + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" } }, "node_modules/which-boxed-primitive": { @@ -14268,6 +14325,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "license": "ISC", + "engines": { + "node": ">=16" + } + }, "node_modules/workerpool": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", diff --git a/package.json b/package.json index f058721b..2b36bcd7 100644 --- a/package.json +++ b/package.json @@ -43,12 +43,14 @@ "@wry/equality": "0.5.7", "cosmiconfig": "9.0.0", "dotenv": "16.4.5", + "fractional-indexing": "2.1.0", "glob": "11.0.0", "graphql": "16.9.0", "graphql-language-service": "5.2.2", "graphql-tag": "2.12.6", "lodash.debounce": "4.0.8", "lodash.merge": "4.6.2", + "lodash.throttle": "4.1.1", "lz-string": "1.5.0", "minimatch": "10.0.1", "moment": "2.30.1", @@ -56,6 +58,7 @@ "vscode-languageserver": "9.0.1", "vscode-languageserver-textdocument": "1.0.12", "vscode-uri": "3.0.8", + "which": "4.0.0", "zod": "3.23.8", "zod-validation-error": "3.3.1" }, @@ -67,6 +70,7 @@ "@types/jest": "29.5.12", "@types/lodash.debounce": "4.0.9", "@types/lodash.merge": "4.6.9", + "@types/lodash.throttle": "^4.1.9", "@types/node": "20.14.10", "@types/vscode": "1.90.0", "@typescript-eslint/eslint-plugin": "6.9.1", diff --git a/renovate.json b/renovate.json index 72cae04b..a9618d36 100644 --- a/renovate.json +++ b/renovate.json @@ -25,6 +25,7 @@ "@types/vscode", "@typescript-eslint/eslint-plugin", "@typescript-eslint/parser", - "eslint" + "eslint", + "fractional-indexing" ] } diff --git a/sampleWorkspace/localSchema/src/test.js b/sampleWorkspace/localSchema/src/test.js index 20004744..abfa3484 100644 --- a/sampleWorkspace/localSchema/src/test.js +++ b/sampleWorkspace/localSchema/src/test.js @@ -6,3 +6,6 @@ gql` } } `; + +// prettier-ignore +const verylonglala = gql`type Foo { baaaaaar: String }` diff --git a/sampleWorkspace/rover/apollo.config.js b/sampleWorkspace/rover/apollo.config.js new file mode 100644 index 00000000..10de9ebc --- /dev/null +++ b/sampleWorkspace/rover/apollo.config.js @@ -0,0 +1,3 @@ +module.exports = { + rover: {}, +}; diff --git a/sampleWorkspace/rover/src/test.graphql b/sampleWorkspace/rover/src/test.graphql new file mode 100644 index 00000000..4a7749b7 --- /dev/null +++ b/sampleWorkspace/rover/src/test.graphql @@ -0,0 +1,14 @@ +""" +The query type, represents all of the entry points into our object graph +""" +type Query { + me: User! +} + +""" +Test +""" +type User { + id: ID! + name: String! +} diff --git a/sampleWorkspace/rover/src/test.js b/sampleWorkspace/rover/src/test.js new file mode 100644 index 00000000..f83fdd1e --- /dev/null +++ b/sampleWorkspace/rover/src/test.js @@ -0,0 +1,30 @@ +import gql from "graphql-tag"; + +sdfsdfs; +gql` + """ + The query type, represents all of the entry points into our object graph + """ + type Query { + me: User! + } + + """ + Test + """ + type User { + id: ID! + name: String! + } +`; + +console.log("foobar!"); + +gql` + type User { + lastName: String! + } +`; + +// prettier-ignore +const verylonglala = gql`type Foo { baaaaaar: String }` diff --git a/sampleWorkspace/sampleWorkspace.code-workspace b/sampleWorkspace/sampleWorkspace.code-workspace index 82812164..bd11d1c0 100644 --- a/sampleWorkspace/sampleWorkspace.code-workspace +++ b/sampleWorkspace/sampleWorkspace.code-workspace @@ -14,6 +14,9 @@ }, { "path": "localSchemaArray" + }, + { + "path": "rover" } ], "settings": {} diff --git a/src/language-server/config/__tests__/loadConfig.ts b/src/language-server/config/__tests__/loadConfig.ts index c344b7ca..a97f61ba 100644 --- a/src/language-server/config/__tests__/loadConfig.ts +++ b/src/language-server/config/__tests__/loadConfig.ts @@ -122,18 +122,27 @@ Object { } `, }); - - const config = await loadConfig({ - configPath: dirPath, - }); - expect(config?.rawConfig).toMatchInlineSnapshot(` + fs.mkdirSync(`${dir}/bin`); + fs.writeFileSync(`${dir}/bin/rover`, "", { mode: 0o755 }); + let oldPath = process.env.PATH; + process.env.PATH = `${dir}/bin:${oldPath}`; + try { + const config = await loadConfig({ + configPath: dirPath, + }); + expect(config?.rawConfig).toMatchInlineSnapshot(` Object { "engine": Object { "endpoint": "https://graphql.api.apollographql.com/api/graphql", }, - "rover": Object {}, + "rover": Object { + "bin": "${dir}/bin/rover", + }, } `); + } finally { + process.env.PATH = oldPath; + } })); it("[deprecated] loads config from package.json", async () => { @@ -267,7 +276,7 @@ Object { describe("env loading", () => { it("finds .env in config path & parses for key", async () => { writeFilesToDir(dir, { - "apollo.config.js": `module.exports = { client: { name: 'hello' } }`, + "apollo.config.js": `module.exports = { client: { } }`, ".env": `APOLLO_KEY=service:harambe:54378950jn`, }); @@ -280,7 +289,7 @@ Object { it("finds .env.local in config path & parses for key", async () => { writeFilesToDir(dir, { - "apollo.config.js": `module.exports = { client: { name: 'hello' } }`, + "apollo.config.js": `module.exports = { client: { } }`, ".env.local": `APOLLO_KEY=service:harambe:54378950jn`, }); @@ -293,7 +302,7 @@ Object { it("finds .env and .env.local in config path & parses for key, preferring .env.local", async () => { writeFilesToDir(dir, { - "apollo.config.js": `module.exports = { client: { name: 'hello' } }`, + "apollo.config.js": `module.exports = { client: { } }`, ".env": `APOLLO_KEY=service:hamato:54378950jn`, ".env.local": `APOLLO_KEY=service:yoshi:65489061ko`, }); @@ -337,7 +346,7 @@ Object { it("infers rover projects from config", () => withFeatureFlags("rover", async () => { writeFilesToDir(dir, { - "apollo.config.js": `module.exports = { rover: {} }`, + "apollo.config.js": `module.exports = { rover: { bin: "/usr/bin/env" } }`, }); const config = await loadConfig({ diff --git a/src/language-server/config/config.ts b/src/language-server/config/config.ts index 18f0d133..91463fc1 100644 --- a/src/language-server/config/config.ts +++ b/src/language-server/config/config.ts @@ -6,6 +6,8 @@ import z, { ZodError } from "zod"; import { ValidationRule } from "graphql/validation/ValidationContext"; import { Slot } from "@wry/context"; import { fromZodError } from "zod-validation-error"; +import which from "which"; +import { accessSync, constants as fsConstants, statSync } from "node:fs"; const ROVER_AVAILABLE = (process.env.APOLLO_FEATURE_FLAGS || "") .split(",") @@ -79,7 +81,30 @@ const clientConfig = z.object({ export type ClientConfigFormat = z.infer; const roverConfig = z.object({ - bin: z.string().optional(), + bin: z + .preprocess( + (val) => val || which.sync("rover", { nothrow: true }) || undefined, + z.string({ + message: + "Rover binary not found. Please either install it system-wide in PATH, or provide the `bin` option. Also ensure that the binary is executable.", + }), + ) + .refine( + (bin) => { + try { + // is executable? + accessSync(bin, fsConstants.X_OK); + // is a file and not a directory? + return statSync(bin).isFile(); + } catch { + return false; + } + }, + { + message: + "Rover binary is not marked as an executable. If you are using OS X or Linux, ensure to set the executable bit.", + }, + ), profile: z.string().optional(), }); type RoverConfigFormat = z.infer; diff --git a/src/language-server/config/which.d.ts b/src/language-server/config/which.d.ts new file mode 100644 index 00000000..506465fa --- /dev/null +++ b/src/language-server/config/which.d.ts @@ -0,0 +1,19 @@ +declare module "which" { + interface Options { + /** Use instead of the PATH environment variable. */ + path?: string; + /** Use instead of the PATHEXT environment variable. */ + pathExt?: string; + /** Return all matches, instead of just the first one. Note that this means the function returns an array of strings instead of a single string. */ + all?: boolean; + } + + function which(cmd: string, options?: Options): number; + namespace which { + function sync( + cmd: string, + options?: Options & { nothrow?: boolean }, + ): string | null; + } + export = which; +} diff --git a/src/language-server/document.ts b/src/language-server/document.ts index f764456b..829cfacf 100644 --- a/src/language-server/document.ts +++ b/src/language-server/document.ts @@ -2,7 +2,6 @@ import { parse, Source, DocumentNode } from "graphql"; import { SourceLocation, getLocation } from "graphql/language/location"; import { - TextDocument, Position, Diagnostic, DiagnosticSeverity, @@ -14,6 +13,7 @@ import { positionFromSourceLocation, rangeInContainingDocument, } from "./utilities/source"; +import { TextDocument } from "vscode-languageserver-textdocument"; export class GraphQLDocument { ast?: DocumentNode; @@ -51,44 +51,51 @@ export class GraphQLDocument { } } -export function extractGraphQLDocuments( +export function extractGraphQLSources( document: TextDocument, tagName: string = "gql", -): GraphQLDocument[] | null { +): Source[] | null { switch (document.languageId) { case "graphql": - return [ - new GraphQLDocument(new Source(document.getText(), document.uri)), - ]; + return [new Source(document.getText(), document.uri)]; case "javascript": case "javascriptreact": case "typescript": case "typescriptreact": case "vue": case "svelte": - return extractGraphQLDocumentsFromJSTemplateLiterals(document, tagName); + return extractGraphQLSourcesFromJSTemplateLiterals(document, tagName); case "python": - return extractGraphQLDocumentsFromPythonStrings(document, tagName); + return extractGraphQLSourcesFromPythonStrings(document, tagName); case "ruby": - return extractGraphQLDocumentsFromRubyStrings(document, tagName); + return extractGraphQLSourcesFromRubyStrings(document, tagName); case "dart": - return extractGraphQLDocumentsFromDartStrings(document, tagName); + return extractGraphQLSourcesFromDartStrings(document, tagName); case "reason": - return extractGraphQLDocumentsFromReasonStrings(document, tagName); + return extractGraphQLSourcesFromReasonStrings(document, tagName); case "elixir": - return extractGraphQLDocumentsFromElixirStrings(document, tagName); + return extractGraphQLSourcesFromElixirStrings(document, tagName); default: return null; } } -function extractGraphQLDocumentsFromJSTemplateLiterals( +export function extractGraphQLDocuments( document: TextDocument, - tagName: string, + tagName: string = "gql", ): GraphQLDocument[] | null { + const sources = extractGraphQLSources(document, tagName); + if (!sources) return null; + return sources.map((source) => new GraphQLDocument(source)); +} + +function extractGraphQLSourcesFromJSTemplateLiterals( + document: TextDocument, + tagName: string, +): Source[] | null { const text = document.getText(); - const documents: GraphQLDocument[] = []; + const sources: Source[] = []; const regExp = new RegExp( `(?:${tagName}(?:\\s|\\()*\`|\`#graphql)([\\s\\S]+?)\`\\)?`, @@ -104,21 +111,21 @@ function extractGraphQLDocumentsFromJSTemplateLiterals( column: position.character + 1, }; const source = new Source(contents, document.uri, locationOffset); - documents.push(new GraphQLDocument(source)); + sources.push(source); } - if (documents.length < 1) return null; + if (sources.length < 1) return null; - return documents; + return sources; } -function extractGraphQLDocumentsFromPythonStrings( +function extractGraphQLSourcesFromPythonStrings( document: TextDocument, tagName: string, -): GraphQLDocument[] | null { +): Source[] | null { const text = document.getText(); - const documents: GraphQLDocument[] = []; + const sources: Source[] = []; const regExp = new RegExp( `\\b(${tagName}\\s*\\(\\s*[bfru]*("(?:"")?|'(?:'')?))([\\s\\S]+?)\\2\\s*\\)`, @@ -134,21 +141,21 @@ function extractGraphQLDocumentsFromPythonStrings( column: position.character + 1, }; const source = new Source(contents, document.uri, locationOffset); - documents.push(new GraphQLDocument(source)); + sources.push(source); } - if (documents.length < 1) return null; + if (sources.length < 1) return null; - return documents; + return sources; } -function extractGraphQLDocumentsFromRubyStrings( +function extractGraphQLSourcesFromRubyStrings( document: TextDocument, tagName: string, -): GraphQLDocument[] | null { +): Source[] | null { const text = document.getText(); - const documents: GraphQLDocument[] = []; + const sources: Source[] = []; const regExp = new RegExp(`(<<-${tagName})([\\s\\S]+?)${tagName}`, "gm"); @@ -161,21 +168,21 @@ function extractGraphQLDocumentsFromRubyStrings( column: position.character + 1, }; const source = new Source(contents, document.uri, locationOffset); - documents.push(new GraphQLDocument(source)); + sources.push(source); } - if (documents.length < 1) return null; + if (sources.length < 1) return null; - return documents; + return sources; } -function extractGraphQLDocumentsFromDartStrings( +function extractGraphQLSourcesFromDartStrings( document: TextDocument, tagName: string, -): GraphQLDocument[] | null { +): Source[] | null { const text = document.getText(); - const documents: GraphQLDocument[] = []; + const sources: Source[] = []; const regExp = new RegExp( `\\b(${tagName}\\(\\s*r?("""|'''))([\\s\\S]+?)\\2\\s*\\)`, @@ -191,26 +198,26 @@ function extractGraphQLDocumentsFromDartStrings( column: position.character + 1, }; const source = new Source(contents, document.uri, locationOffset); - documents.push(new GraphQLDocument(source)); + sources.push(source); } - if (documents.length < 1) return null; + if (sources.length < 1) return null; - return documents; + return sources; } -function extractGraphQLDocumentsFromReasonStrings( +function extractGraphQLSourcesFromReasonStrings( document: TextDocument, tagName: string, -): GraphQLDocument[] | null { +): Source[] | null { const text = document.getText(); - const documents: GraphQLDocument[] = []; + const sources: Source[] = []; const reasonFileFilter = new RegExp(/(\[%(graphql|relay\.))/g); if (!reasonFileFilter.test(text)) { - return documents; + return sources; } const reasonRegexp = new RegExp( @@ -226,20 +233,20 @@ function extractGraphQLDocumentsFromReasonStrings( column: position.character + 1, }; const source = new Source(contents, document.uri, locationOffset); - documents.push(new GraphQLDocument(source)); + sources.push(source); } - if (documents.length < 1) return null; + if (sources.length < 1) return null; - return documents; + return sources; } -function extractGraphQLDocumentsFromElixirStrings( +function extractGraphQLSourcesFromElixirStrings( document: TextDocument, tagName: string, -): GraphQLDocument[] | null { +): Source[] | null { const text = document.getText(); - const documents: GraphQLDocument[] = []; + const sources: Source[] = []; const regExp = new RegExp( `\\b(${tagName}\\(\\s*r?("""))([\\s\\S]+?)\\2\\s*\\)`, @@ -255,12 +262,12 @@ function extractGraphQLDocumentsFromElixirStrings( column: position.character + 1, }; const source = new Source(contents, document.uri, locationOffset); - documents.push(new GraphQLDocument(source)); + sources.push(source); } - if (documents.length < 1) return null; + if (sources.length < 1) return null; - return documents; + return sources; } function replacePlaceholdersWithWhiteSpace(content: string) { diff --git a/src/language-server/fileSet.ts b/src/language-server/fileSet.ts index 26685427..a5fc0ed3 100644 --- a/src/language-server/fileSet.ts +++ b/src/language-server/fileSet.ts @@ -30,6 +30,13 @@ export class FileSet { this.excludes = excludes; } + pushIncludes(files: string[]) { + this.includes.push(...files); + } + pushExcludes(files: string[]) { + this.excludes.push(...files); + } + includesFile(filePath: string): boolean { const normalizedFilePath = normalizeURI(filePath); diff --git a/src/language-server/project/base.ts b/src/language-server/project/base.ts index c7ca3a4b..2606b8d9 100644 --- a/src/language-server/project/base.ts +++ b/src/language-server/project/base.ts @@ -1,97 +1,51 @@ -import path, { extname } from "path"; -import { lstatSync, readFileSync } from "fs"; import { URI } from "vscode-uri"; -import { - TypeSystemDefinitionNode, - isTypeSystemDefinitionNode, - TypeSystemExtensionNode, - isTypeSystemExtensionNode, - DefinitionNode, - GraphQLSchema, - Kind, -} from "graphql"; +import { GraphQLSchema } from "graphql"; import { - TextDocument, NotificationHandler, PublishDiagnosticsParams, - Position, CancellationToken, - CompletionItem, - Hover, - Definition, - ReferenceContext, - Location, - DocumentSymbol, SymbolInformation, - Range, - CodeAction, - CodeLens, + Connection, + ServerRequestHandler, + TextDocumentChangeEvent, } from "vscode-languageserver/node"; - -import { GraphQLDocument, extractGraphQLDocuments } from "../document"; +import { TextDocument } from "vscode-languageserver-textdocument"; import type { LoadingHandler } from "../loadingHandler"; import { FileSet } from "../fileSet"; -import { - ApolloConfig, - ClientConfig, - isClientConfig, - isLocalServiceConfig, - keyEnvVar, - RoverConfig, -} from "../config"; -import { - schemaProviderFromConfig, - GraphQLSchemaProvider, - SchemaResolveConfig, -} from "../providers/schema"; -import { ApolloEngineClient, ClientIdentity } from "../engine"; +import { ApolloConfig, ClientConfig, RoverConfig } from "../config"; import type { ProjectStats } from "../../messages"; export type DocumentUri = string; -const fileAssociations: { [extension: string]: string } = { - ".graphql": "graphql", - ".gql": "graphql", - ".js": "javascript", - ".ts": "typescript", - ".jsx": "javascriptreact", - ".tsx": "typescriptreact", - ".vue": "vue", - ".svelte": "svelte", - ".py": "python", - ".rb": "ruby", - ".dart": "dart", - ".re": "reason", - ".ex": "elixir", - ".exs": "elixir", -}; - -interface GraphQLProjectConfig { - clientIdentity: ClientIdentity; +export interface GraphQLProjectConfig { config: ClientConfig | RoverConfig; configFolderURI: URI; loadingHandler: LoadingHandler; } -export abstract class GraphQLProject implements GraphQLSchemaProvider { - public schemaProvider: GraphQLSchemaProvider; +type ConnectionHandler = { + [K in keyof Connection as K extends `on${string}` + ? K + : never]: Connection[K] extends ( + params: ServerRequestHandler & infer P, + token: CancellationToken, + ) => any + ? P + : never; +}; + +export abstract class GraphQLProject { protected _onDiagnostics?: NotificationHandler; private _isReady: boolean; private readyPromise: Promise; - protected engineClient?: ApolloEngineClient; - - private needsValidation = false; - - protected documentsByFile: Map = new Map(); - public config: ApolloConfig; - public schema?: GraphQLSchema; - private fileSet: FileSet; - private rootURI: URI; + protected schema?: GraphQLSchema; + protected fileSet: FileSet; + protected rootURI: URI; protected loadingHandler: LoadingHandler; protected lastLoadDate?: number; @@ -100,7 +54,6 @@ export abstract class GraphQLProject implements GraphQLSchemaProvider { config, configFolderURI, loadingHandler, - clientIdentity, }: GraphQLProjectConfig) { this.config = config; this.loadingHandler = loadingHandler; @@ -108,41 +61,26 @@ export abstract class GraphQLProject implements GraphQLSchemaProvider { // if a config doesn't have a uri associated, we can assume the `rootURI` is the project's root. this.rootURI = config.configDirURI || configFolderURI; - const { includes = [], excludes = [] } = isClientConfig(config) - ? config.client - : { - /** TODO */ - }; - const fileSet = new FileSet({ + this.fileSet = new FileSet({ rootURI: this.rootURI, includes: [ - ...includes, ".env", "apollo.config.js", "apollo.config.cjs", "apollo.config.mjs", "apollo.config.ts", ], - // We do not want to include the local schema file in our list of documents - excludes: [...excludes, ...this.getRelativeLocalSchemaFilePaths()], + excludes: [], configURI: config.configURI, }); - this.fileSet = fileSet; - this.schemaProvider = schemaProviderFromConfig(config, clientIdentity); - const { engine } = config; - if (engine.apiKey) { - this.engineClient = new ApolloEngineClient( - engine.apiKey!, - engine.endpoint, - clientIdentity, - ); - } - this._isReady = false; - // FIXME: Instead of `Promise.all`, we should catch individual promise rejections - // so we can show multiple errors. - this.readyPromise = Promise.all(this.initialize()) + this.readyPromise = Promise.resolve() + .then( + // FIXME: Instead of `Promise.all`, we should catch individual promise rejections + // so we can show multiple errors. + () => Promise.all(this.initialize()), + ) .then(() => { this._isReady = true; }) @@ -156,7 +94,7 @@ export abstract class GraphQLProject implements GraphQLSchemaProvider { abstract get displayName(): string; - protected abstract initialize(): Promise[]; + abstract initialize(): Promise[]; abstract getProjectStats(): ProjectStats; @@ -164,15 +102,6 @@ export abstract class GraphQLProject implements GraphQLSchemaProvider { return this._isReady; } - get engine(): ApolloEngineClient { - // handle error states for missing engine config - // all in the same place :tada: - if (!this.engineClient) { - throw new Error(`Unable to find ${keyEnvVar}`); - } - return this.engineClient!; - } - get whenReady(): Promise { return this.readyPromise; } @@ -182,282 +111,28 @@ export abstract class GraphQLProject implements GraphQLSchemaProvider { return this.initialize(); } - public resolveSchema(config: SchemaResolveConfig): Promise { - this.lastLoadDate = +new Date(); - return this.schemaProvider.resolveSchema(config); - } - - public resolveFederatedServiceSDL() { - return this.schemaProvider.resolveFederatedServiceSDL(); - } - - public onSchemaChange(handler: NotificationHandler) { - this.lastLoadDate = +new Date(); - return this.schemaProvider.onSchemaChange(handler); - } - onDiagnostics(handler: NotificationHandler) { this._onDiagnostics = handler; } - includesFile(uri: DocumentUri) { - return this.fileSet.includesFile(uri); - } - - allIncludedFiles() { - return this.fileSet.allFiles(); - } - - async scanAllIncludedFiles() { - await this.loadingHandler.handle( - `Loading queries for ${this.displayName}`, - (async () => { - for (const filePath of this.allIncludedFiles()) { - const uri = URI.file(filePath).toString(); - - // If we already have query documents for this file, that means it was either - // opened or changed before we got a chance to read it. - if (this.documentsByFile.has(uri)) continue; - - this.fileDidChange(uri); - } - })(), - ); - } - - fileDidChange(uri: DocumentUri) { - const filePath = URI.parse(uri).fsPath; - const extension = extname(filePath); - const languageId = fileAssociations[extension]; - - // Don't process files of an unsupported filetype - if (!languageId) return; - - // Don't process directories. Directories might be named like files so - // we have to explicitly check. - if (!lstatSync(filePath).isFile()) return; - - const contents = readFileSync(filePath, "utf8"); - const document = TextDocument.create(uri, languageId, -1, contents); - this.documentDidChange(document); - } - - fileWasDeleted(uri: DocumentUri) { - this.removeGraphQLDocumentsFor(uri); - this.checkForDuplicateOperations(); - } - - documentDidChange(document: TextDocument) { - const documents = extractGraphQLDocuments( - document, - this.config.client && this.config.client.tagName, - ); - if (documents) { - this.documentsByFile.set(document.uri, documents); - this.invalidate(); - } else { - this.removeGraphQLDocumentsFor(document.uri); - } - this.checkForDuplicateOperations(); - } - - checkForDuplicateOperations(): void { - const filePathForOperationName: Record = {}; - for (const [fileUri, documentsForFile] of this.documentsByFile.entries()) { - const filePath = URI.parse(fileUri).fsPath; - for (const document of documentsForFile) { - if (!document.ast) continue; - for (const definition of document.ast.definitions) { - if ( - definition.kind === Kind.OPERATION_DEFINITION && - definition.name - ) { - const operationName = definition.name.value; - if (operationName in filePathForOperationName) { - const conflictingFilePath = - filePathForOperationName[operationName]; - throw new Error( - `️️There are multiple definitions for the \`${definition.name.value}\` operation. Please fix all naming conflicts before continuing.\nConflicting definitions found at ${filePath} and ${conflictingFilePath}.`, - ); - } - filePathForOperationName[operationName] = filePath; - } - } - } - } - } + abstract includesFile(uri: DocumentUri): boolean; - private removeGraphQLDocumentsFor(uri: DocumentUri) { - if (this.documentsByFile.has(uri)) { - this.documentsByFile.delete(uri); - - if (this._onDiagnostics) { - this._onDiagnostics({ uri: uri, diagnostics: [] }); - } - - this.invalidate(); - } - } - - protected invalidate() { - if (!this.needsValidation && this.isReady) { - setTimeout(() => { - this.validateIfNeeded(); - }, 0); - this.needsValidation = true; - } - } + abstract onDidChangeWatchedFiles: ConnectionHandler["onDidChangeWatchedFiles"]; + abstract onDidOpen?: (event: TextDocumentChangeEvent) => void; + abstract onDidClose?: (event: TextDocumentChangeEvent) => void; + abstract documentDidChange(document: TextDocument): void; + abstract clearAllDiagnostics(): void; - private validateIfNeeded() { - if (!this.needsValidation || !this.isReady) return; - - this.validate(); - - this.needsValidation = false; - } - - private getRelativeLocalSchemaFilePaths(): string[] { - const serviceConfig = - isClientConfig(this.config) && - typeof this.config.client.service === "object" && - isLocalServiceConfig(this.config.client.service) - ? this.config.client.service - : undefined; - const localSchemaFile = serviceConfig?.localSchemaFile; - return ( - localSchemaFile === undefined - ? [] - : Array.isArray(localSchemaFile) - ? localSchemaFile - : [localSchemaFile] - ).map((filePath) => - path.relative(this.rootURI.fsPath, path.join(process.cwd(), filePath)), - ); - } - - abstract validate(): void; - - clearAllDiagnostics() { - if (!this._onDiagnostics) return; - - for (const uri of this.documentsByFile.keys()) { - this._onDiagnostics({ uri, diagnostics: [] }); - } - } - - documentsAt(uri: DocumentUri): GraphQLDocument[] | undefined { - return this.documentsByFile.get(uri); - } - - documentAt( - uri: DocumentUri, - position: Position, - ): GraphQLDocument | undefined { - const queryDocuments = this.documentsByFile.get(uri); - if (!queryDocuments) return undefined; - - return queryDocuments.find((document) => - document.containsPosition(position), - ); - } - - get documents(): GraphQLDocument[] { - const documents: GraphQLDocument[] = []; - for (const documentsForFile of this.documentsByFile.values()) { - documents.push(...documentsForFile); - } - return documents; - } - - get definitions(): DefinitionNode[] { - const definitions = []; - - for (const document of this.documents) { - if (!document.ast) continue; - - definitions.push(...document.ast.definitions); - } - - return definitions; - } - - definitionsAt(uri: DocumentUri): DefinitionNode[] { - const documents = this.documentsAt(uri); - if (!documents) return []; - - const definitions = []; - - for (const document of documents) { - if (!document.ast) continue; - - definitions.push(...document.ast.definitions); - } - - return definitions; - } - - get typeSystemDefinitionsAndExtensions(): ( - | TypeSystemDefinitionNode - | TypeSystemExtensionNode - )[] { - const definitionsAndExtensions = []; - for (const document of this.documents) { - if (!document.ast) continue; - for (const definition of document.ast.definitions) { - if ( - isTypeSystemDefinitionNode(definition) || - isTypeSystemExtensionNode(definition) - ) { - definitionsAndExtensions.push(definition); - } - } - } - return definitionsAndExtensions; - } - - abstract provideCompletionItems?( - uri: DocumentUri, - position: Position, - token: CancellationToken, - ): Promise; - - abstract provideHover?( - uri: DocumentUri, - position: Position, - token: CancellationToken, - ): Promise; - - abstract provideDefinition?( - uri: DocumentUri, - position: Position, - token: CancellationToken, - ): Promise; - - abstract provideReferences?( - uri: DocumentUri, - position: Position, - context: ReferenceContext, - token: CancellationToken, - ): Promise; - - abstract provideDocumentSymbol?( - uri: DocumentUri, - token: CancellationToken, - ): Promise; + abstract onCompletion?: ConnectionHandler["onCompletion"]; + abstract onHover?: ConnectionHandler["onHover"]; + abstract onDefinition?: ConnectionHandler["onDefinition"]; + abstract onReferences?: ConnectionHandler["onReferences"]; + abstract onDocumentSymbol?: ConnectionHandler["onDocumentSymbol"]; + abstract onCodeLens?: ConnectionHandler["onCodeLens"]; + abstract onCodeAction?: ConnectionHandler["onCodeAction"]; abstract provideSymbol?( query: string, token: CancellationToken, ): Promise; - - abstract provideCodeLenses?( - uri: DocumentUri, - token: CancellationToken, - ): Promise; - - abstract provideCodeAction?( - uri: DocumentUri, - range: Range, - token: CancellationToken, - ): Promise; } diff --git a/src/language-server/project/client.ts b/src/language-server/project/client.ts index c5f09eaa..cdc32911 100644 --- a/src/language-server/project/client.ts +++ b/src/language-server/project/client.ts @@ -47,12 +47,7 @@ import { CancellationToken, Position, Location, - Range, - CompletionItem, - Hover, - Definition, CodeLens, - ReferenceContext, InsertTextFormat, DocumentSymbol, SymbolKind, @@ -108,6 +103,7 @@ import { isNotNullOrUndefined } from "../../tools"; import type { CodeActionInfo } from "../errors/validation"; import { GraphQLDiagnostic } from "../diagnostics"; import { isInterfaceType } from "graphql"; +import { GraphQLInternalProject } from "./internal"; type Maybe = null | undefined | T; @@ -180,7 +176,7 @@ export interface GraphQLClientProjectConfig { configFolderURI: URI; loadingHandler: LoadingHandler; } -export class GraphQLClientProject extends GraphQLProject { +export class GraphQLClientProject extends GraphQLInternalProject { public serviceID?: string; public config!: ClientConfig; @@ -619,11 +615,10 @@ export class GraphQLClientProject extends GraphQLProject { return this.engineClient ? this.config.graph : undefined; } - async provideCompletionItems( - uri: DocumentUri, - position: Position, + onCompletion: GraphQLProject["onCompletion"] = async ( + { textDocument: { uri }, position }, _token: CancellationToken, - ): Promise { + ) => { const document = this.documentAt(uri, position); if (!document) return []; @@ -769,13 +764,12 @@ export class GraphQLClientProject extends GraphQLProject { } return suggestions; - } + }; - async provideHover( - uri: DocumentUri, - position: Position, - _token: CancellationToken, - ): Promise { + onHover: GraphQLProject["onHover"] = async ( + { textDocument: { uri }, position }, + _token, + ) => { const document = this.documentAt(uri, position); if (!(document && document.ast)) return null; @@ -929,13 +923,12 @@ export class GraphQLClientProject extends GraphQLProject { } } return null; - } + }; - async provideDefinition( - uri: DocumentUri, - position: Position, - _token: CancellationToken, - ): Promise { + onDefinition: GraphQLProject["onDefinition"] = async ({ + position, + textDocument: { uri }, + }) => { const document = this.documentAt(uri, position); if (!(document && document.ast)) return null; @@ -989,14 +982,12 @@ export class GraphQLClientProject extends GraphQLProject { } } return null; - } + }; - async provideReferences( - uri: DocumentUri, - position: Position, - _context: ReferenceContext, - _token: CancellationToken, - ): Promise { + onReferences: GraphQLProject["onReferences"] = async ({ + position, + textDocument: { uri }, + }) => { const document = this.documentAt(uri, position); if (!(document && document.ast)) return null; @@ -1067,12 +1058,11 @@ export class GraphQLClientProject extends GraphQLProject { } return null; - } + }; - async provideDocumentSymbol( - uri: DocumentUri, - _token: CancellationToken, - ): Promise { + onDocumentSymbol: GraphQLProject["onDocumentSymbol"] = async ({ + textDocument: { uri }, + }) => { const definitions = this.definitionsAt(uri); const symbols: DocumentSymbol[] = []; @@ -1113,7 +1103,7 @@ export class GraphQLClientProject extends GraphQLProject { } return symbols; - } + }; async provideSymbol( _query: string, @@ -1136,10 +1126,9 @@ export class GraphQLClientProject extends GraphQLProject { return symbols; } - async provideCodeLenses( - uri: DocumentUri, - _token: CancellationToken, - ): Promise { + onCodeLens: GraphQLProject["onCodeLens"] = async ({ + textDocument: { uri }, + }) => { // Wait for the project to be fully initialized, so we always provide code lenses for open files, even // if we receive the request before the project is ready. await this.whenReady; @@ -1216,13 +1205,12 @@ export class GraphQLClientProject extends GraphQLProject { } } return codeLenses; - } + }; - async provideCodeAction( - uri: DocumentUri, - range: Range, - _token: CancellationToken, - ): Promise { + onCodeAction: GraphQLProject["onCodeAction"] = async ({ + textDocument: { uri }, + range, + }) => { function isPositionLessThanOrEqual(a: Position, b: Position) { return a.line !== b.line ? a.line < b.line : a.character <= b.character; } @@ -1268,7 +1256,7 @@ export class GraphQLClientProject extends GraphQLProject { } return result; - } + }; } function buildExplorerURL({ diff --git a/src/language-server/project/internal.ts b/src/language-server/project/internal.ts new file mode 100644 index 00000000..4d83434b --- /dev/null +++ b/src/language-server/project/internal.ts @@ -0,0 +1,346 @@ +import path, { extname } from "path"; +import { lstatSync, readFileSync } from "fs"; +import { URI } from "vscode-uri"; + +import { + TypeSystemDefinitionNode, + isTypeSystemDefinitionNode, + TypeSystemExtensionNode, + isTypeSystemExtensionNode, + DefinitionNode, + GraphQLSchema, + Kind, +} from "graphql"; + +import { + FileChangeType, + NotificationHandler, + Position, +} from "vscode-languageserver/node"; +import { TextDocument } from "vscode-languageserver-textdocument"; + +import { GraphQLDocument, extractGraphQLDocuments } from "../document"; + +import { ClientConfig, isClientConfig, isLocalServiceConfig } from "../config"; +import { + schemaProviderFromConfig, + GraphQLSchemaProvider, + SchemaResolveConfig, +} from "../providers/schema"; +import { ApolloEngineClient, ClientIdentity } from "../engine"; +import { GraphQLProject, DocumentUri, GraphQLProjectConfig } from "./base"; +import throttle from "lodash.throttle"; + +const fileAssociations: { [extension: string]: string } = { + ".graphql": "graphql", + ".gql": "graphql", + ".js": "javascript", + ".ts": "typescript", + ".jsx": "javascriptreact", + ".tsx": "typescriptreact", + ".vue": "vue", + ".svelte": "svelte", + ".py": "python", + ".rb": "ruby", + ".dart": "dart", + ".re": "reason", + ".ex": "elixir", + ".exs": "elixir", +}; + +export interface GraphQLInternalProjectConfig extends GraphQLProjectConfig { + config: ClientConfig; + clientIdentity: ClientIdentity; +} +export abstract class GraphQLInternalProject + extends GraphQLProject + implements GraphQLSchemaProvider +{ + public schemaProvider: GraphQLSchemaProvider; + protected engineClient?: ApolloEngineClient; + + private needsValidation = false; + + protected documentsByFile: Map; + + constructor({ + config, + configFolderURI, + loadingHandler, + clientIdentity, + }: GraphQLInternalProjectConfig) { + super({ config, configFolderURI, loadingHandler }); + const { includes = [], excludes = [] } = config.client; + + this.documentsByFile = new Map(); + + this.fileSet.pushIncludes(includes); + // We do not want to include the local schema file in our list of documents + this.fileSet.pushExcludes([ + ...excludes, + ...this.getRelativeLocalSchemaFilePaths(), + ]); + + this.schemaProvider = schemaProviderFromConfig(config, clientIdentity); + const { engine } = config; + if (engine.apiKey) { + this.engineClient = new ApolloEngineClient( + engine.apiKey!, + engine.endpoint, + clientIdentity, + ); + } + } + + public resolveSchema(config: SchemaResolveConfig): Promise { + this.lastLoadDate = +new Date(); + return this.schemaProvider.resolveSchema(config); + } + + public resolveFederatedServiceSDL() { + return this.schemaProvider.resolveFederatedServiceSDL(); + } + + public onSchemaChange(handler: NotificationHandler) { + this.lastLoadDate = +new Date(); + return this.schemaProvider.onSchemaChange(handler); + } + + includesFile(uri: DocumentUri) { + return this.fileSet.includesFile(uri); + } + + allIncludedFiles() { + return this.fileSet.allFiles(); + } + + async scanAllIncludedFiles() { + await this.loadingHandler.handle( + `Loading queries for ${this.displayName}`, + (async () => { + for (const filePath of this.allIncludedFiles()) { + const uri = URI.file(filePath).toString(); + + // If we already have query documents for this file, that means it was either + // opened or changed before we got a chance to read it. + if (this.documentsByFile.has(uri)) continue; + + this.fileDidChange(uri); + } + })(), + ); + } + + fileDidChange(uri: DocumentUri) { + const filePath = URI.parse(uri).fsPath; + const extension = extname(filePath); + const languageId = fileAssociations[extension]; + + // Don't process files of an unsupported filetype + if (!languageId) return; + + // Don't process directories. Directories might be named like files so + // we have to explicitly check. + if (!lstatSync(filePath).isFile()) return; + + const contents = readFileSync(filePath, "utf8"); + const document = TextDocument.create(uri, languageId, -1, contents); + this.documentDidChange(document); + } + + fileWasDeleted(uri: DocumentUri) { + this.removeGraphQLDocumentsFor(uri); + this.checkForDuplicateOperations(); + } + + documentDidChange = (document: TextDocument) => { + const documents = extractGraphQLDocuments( + document, + this.config.client && this.config.client.tagName, + ); + if (documents) { + this.documentsByFile.set(document.uri, documents); + this.invalidate(); + } else { + this.removeGraphQLDocumentsFor(document.uri); + } + this.checkForDuplicateOperations(); + }; + + checkForDuplicateOperations = throttle( + () => { + const filePathForOperationName: Record = {}; + for (const [ + fileUri, + documentsForFile, + ] of this.documentsByFile.entries()) { + const filePath = URI.parse(fileUri).fsPath; + for (const document of documentsForFile) { + if (!document.ast) continue; + for (const definition of document.ast.definitions) { + if ( + definition.kind === Kind.OPERATION_DEFINITION && + definition.name + ) { + const operationName = definition.name.value; + if (operationName in filePathForOperationName) { + const conflictingFilePath = + filePathForOperationName[operationName]; + throw new Error( + `️️There are multiple definitions for the \`${definition.name.value}\` operation. Please fix all naming conflicts before continuing.\nConflicting definitions found at ${filePath} and ${conflictingFilePath}.`, + ); + } + filePathForOperationName[operationName] = filePath; + } + } + } + } + }, + 250, + { leading: true, trailing: true }, + ); + + private removeGraphQLDocumentsFor(uri: DocumentUri) { + if (this.documentsByFile.has(uri)) { + this.documentsByFile.delete(uri); + + if (this._onDiagnostics) { + this._onDiagnostics({ uri: uri, diagnostics: [] }); + } + + this.invalidate(); + } + } + + protected invalidate() { + if (!this.needsValidation && this.isReady) { + setTimeout(() => { + this.validateIfNeeded(); + }, 0); + this.needsValidation = true; + } + } + + private validateIfNeeded() { + if (!this.needsValidation || !this.isReady) return; + + this.validate(); + + this.needsValidation = false; + } + + private getRelativeLocalSchemaFilePaths(): string[] { + const serviceConfig = + isClientConfig(this.config) && + typeof this.config.client.service === "object" && + isLocalServiceConfig(this.config.client.service) + ? this.config.client.service + : undefined; + const localSchemaFile = serviceConfig?.localSchemaFile; + return ( + localSchemaFile === undefined + ? [] + : Array.isArray(localSchemaFile) + ? localSchemaFile + : [localSchemaFile] + ).map((filePath) => + path.relative(this.rootURI.fsPath, path.join(process.cwd(), filePath)), + ); + } + + abstract validate(): void; + + clearAllDiagnostics() { + if (!this._onDiagnostics) return; + + for (const uri of this.documentsByFile.keys()) { + this._onDiagnostics({ uri, diagnostics: [] }); + } + } + + documentsAt(uri: DocumentUri): GraphQLDocument[] | undefined { + return this.documentsByFile.get(uri); + } + + documentAt( + uri: DocumentUri, + position: Position, + ): GraphQLDocument | undefined { + const queryDocuments = this.documentsByFile.get(uri); + if (!queryDocuments) return undefined; + + return queryDocuments.find((document) => + document.containsPosition(position), + ); + } + + get documents(): GraphQLDocument[] { + const documents: GraphQLDocument[] = []; + for (const documentsForFile of this.documentsByFile.values()) { + documents.push(...documentsForFile); + } + return documents; + } + + get definitions(): DefinitionNode[] { + const definitions = []; + + for (const document of this.documents) { + if (!document.ast) continue; + + definitions.push(...document.ast.definitions); + } + + return definitions; + } + + definitionsAt(uri: DocumentUri): DefinitionNode[] { + const documents = this.documentsAt(uri); + if (!documents) return []; + + const definitions = []; + + for (const document of documents) { + if (!document.ast) continue; + + definitions.push(...document.ast.definitions); + } + + return definitions; + } + + get typeSystemDefinitionsAndExtensions(): ( + | TypeSystemDefinitionNode + | TypeSystemExtensionNode + )[] { + const definitionsAndExtensions = []; + for (const document of this.documents) { + if (!document.ast) continue; + for (const definition of document.ast.definitions) { + if ( + isTypeSystemDefinitionNode(definition) || + isTypeSystemExtensionNode(definition) + ) { + definitionsAndExtensions.push(definition); + } + } + } + return definitionsAndExtensions; + } + onDidOpen: undefined; + onDidClose: undefined; + onDidChangeWatchedFiles: GraphQLProject["onDidChangeWatchedFiles"] = ( + params, + ) => { + for (const { uri, type } of params.changes) { + switch (type) { + case FileChangeType.Created: + this.fileDidChange(uri); + break; + case FileChangeType.Deleted: + this.fileWasDeleted(uri); + break; + } + } + }; +} diff --git a/src/language-server/project/rover/DocumentSynchronization.ts b/src/language-server/project/rover/DocumentSynchronization.ts new file mode 100644 index 00000000..38fbb56c --- /dev/null +++ b/src/language-server/project/rover/DocumentSynchronization.ts @@ -0,0 +1,232 @@ +import { extractGraphQLSources } from "../../document"; +import { + ProtocolNotificationType, + DidChangeTextDocumentNotification, + DidOpenTextDocumentNotification, + DidCloseTextDocumentNotification, + TextDocumentPositionParams, +} from "vscode-languageserver-protocol"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { DocumentUri, GraphQLProject } from "../base"; +import { generateKeyBetween } from "fractional-indexing"; +import { Source } from "graphql"; +import { findContainedSourceAndPosition } from "../../utilities/source"; + +export interface FilePart { + fractionalIndex: string; + source: Source; +} + +export function handleFilePartUpdates( + parsed: Source[], + previousParts: FilePart[], +): FilePart[] { + const newParts = []; + let newIdx = 0; + let oldIdx = 0; + let offsetCorrection = 0; + while (newIdx < parsed.length || oldIdx < previousParts.length) { + const source = parsed[newIdx] as Source | undefined; + const oldPart = previousParts[oldIdx] as FilePart | undefined; + if (!source) return newParts; + const newOffset = source.locationOffset.line; + + if ( + oldPart && + (source.body === oldPart.source.body || + newOffset === oldPart.source.locationOffset.line + offsetCorrection) + ) { + // replacement of chunk + newParts.push({ source, fractionalIndex: oldPart.fractionalIndex }); + offsetCorrection = + source.locationOffset.line - oldPart.source.locationOffset.line; + newIdx++; + oldIdx++; + } else if ( + !oldPart || + newOffset < oldPart.source.locationOffset.line + offsetCorrection + ) { + // inserted chunk + const fractionalIndex = generateKeyBetween( + newParts.length == 0 + ? null + : newParts[newParts.length - 1].fractionalIndex, + oldPart ? oldPart.fractionalIndex : null, + ); + newParts.push({ source, fractionalIndex }); + newIdx++; + offsetCorrection += source.body.split("\n").length - 1; + } else { + // deleted chunk + oldIdx++; + } + } + return newParts; +} + +function getUri(part: FilePart) { + return part.source.name + "/" + part.fractionalIndex + ".graphql"; +} + +export class DocumentSynchronization { + private pendingDocumentChanges = new Map(); + private knownFiles = new Map< + DocumentUri, + { + full: TextDocument; + parts: FilePart[]; + } + >(); + + constructor( + private sendNotification: ( + type: ProtocolNotificationType, + params?: P, + ) => Promise, + ) {} + + private documentSynchronizationScheduled = false; + /** + * Ensures that only one `syncNextDocumentChange` is queued with the connection at a time. + * As a result, other, more important, changes can be processed with higher priority. + */ + private scheduleDocumentSync = async () => { + if ( + this.pendingDocumentChanges.size === 0 || + this.documentSynchronizationScheduled + ) { + return; + } + + this.documentSynchronizationScheduled = true; + try { + const next = this.pendingDocumentChanges.values().next(); + if (next.done) return; + await this.sendDocumentChanges(next.value); + } finally { + this.documentSynchronizationScheduled = false; + setImmediate(this.scheduleDocumentSync); + } + }; + + private async sendDocumentChanges(document: TextDocument) { + this.pendingDocumentChanges.delete(document.uri); + + const previousParts = this.knownFiles.get(document.uri)?.parts || []; + const previousObj = Object.fromEntries( + previousParts.map((p) => [p.fractionalIndex, p]), + ); + const newParts = handleFilePartUpdates( + extractGraphQLSources(document) || [], + previousParts, + ); + const newObj = Object.fromEntries( + newParts.map((p) => [p.fractionalIndex, p]), + ); + this.knownFiles.set(document.uri, { full: document, parts: newParts }); + + for (const newPart of newParts) { + const previousPart = previousObj[newPart.fractionalIndex]; + if (!previousPart) { + await this.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: getUri(newPart), + languageId: "graphql", + version: document.version, + text: newPart.source.body, + }, + }); + } else if (newPart.source.body !== previousPart.source.body) { + await this.sendNotification(DidChangeTextDocumentNotification.type, { + textDocument: { + uri: getUri(newPart), + version: document.version, + }, + contentChanges: [ + { + text: newPart.source.body, + }, + ], + }); + } + } + for (const previousPart of previousParts) { + if (!newObj[previousPart.fractionalIndex]) { + await this.sendNotification(DidCloseTextDocumentNotification.type, { + textDocument: { + uri: getUri(previousPart), + }, + }); + } + } + } + + onDidOpenTextDocument: NonNullable = async ( + params, + ) => { + this.documentDidChange(params.document); + }; + + onDidCloseTextDocument: NonNullable = ( + params, + ) => { + const known = this.knownFiles.get(params.document.uri); + if (!known) { + return; + } + this.knownFiles.delete(params.document.uri); + return Promise.all( + known.parts.map((part) => + this.sendNotification(DidCloseTextDocumentNotification.type, { + textDocument: { + uri: getUri(part), + }, + }), + ), + ); + }; + + async documentDidChange(document: TextDocument) { + if (this.pendingDocumentChanges.has(document.uri)) { + // this will put the document at the end of the queue again + // in hopes that we can skip a bit of unnecessary work sometimes + // when many files change around a lot + // we will always ensure that a document is synchronized via `synchronizedWithDocument` + // before we do other operations on the document, so this is safe + this.pendingDocumentChanges.delete(document.uri); + } + + this.pendingDocumentChanges.set(document.uri, document); + this.scheduleDocumentSync(); + } + + async synchronizedWithDocument(documentUri: DocumentUri): Promise { + const document = this.pendingDocumentChanges.get(documentUri); + if (document) { + await this.sendDocumentChanges(document); + } + } + + async insideVirtualDocument( + positionParams: TextDocumentPositionParams, + cb: (virtualPositionParams: TextDocumentPositionParams) => Promise, + ): Promise { + await this.synchronizedWithDocument(positionParams.textDocument.uri); + const found = this.knownFiles.get(positionParams.textDocument.uri); + if (!found) { + return; + } + const match = findContainedSourceAndPosition( + found.parts, + positionParams.position, + ); + + if (!match) return; + return cb({ + textDocument: { + uri: getUri(match), + }, + position: match.position, + }); + } +} diff --git a/src/language-server/project/rover/__tests__/DocumentSynchronization.test.ts b/src/language-server/project/rover/__tests__/DocumentSynchronization.test.ts new file mode 100644 index 00000000..36af03c2 --- /dev/null +++ b/src/language-server/project/rover/__tests__/DocumentSynchronization.test.ts @@ -0,0 +1,290 @@ +import { Source } from "graphql"; +import { extractGraphQLSources } from "../../../document"; +import { handleFilePartUpdates } from "../DocumentSynchronization"; +import { TextDocument } from "vscode-languageserver-textdocument"; + +const initialFile = ` +Test +gql\` +query Test1 { + test +} +\` + +More test + +gql\` +query Test2 { + test +} +\` +`; + +const editedFile = ` +Test edited +foo +bar +gql\` +query Test1 { + test +} +\` + +More test lalala + +gql\` +query Test2 { + test +} +\` +More stuff here +`; + +const insertedFile = ` +Test edited +foo +bar +gql\` +query Test1 { + test +} +\` +More test lalala + +gql\` +query Test3 { + test +} +\` +More test lalala + +gql\` +query Test2 { + test +} +\` +More stuff here +`; + +const pushedFile = ` +Test edited +foo +bar +gql\` +query Test1 { + test +} +\` +More test lalala + +gql\` +query Test2 { + test +} +\` +More test lalala + +gql\` +query Test3 { + test +} +\` +More stuff here +`; + +const shiftedFile = ` +Test +More test + +gql\` +query Test2 { + test +} +\` +`; + +const poppedFile = ` +Test +gql\` +query Test1 { + test +} +\` + +More test + +`; + +const query1 = ` +query Test1 { + test +} +`; +const query2 = ` +query Test2 { + test +} +`; +const query3 = ` +query Test3 { + test +} +`; + +describe("handleFilePartUpdates", () => { + const initialUpdates = handleFilePartUpdates( + extractGraphQLSources( + TextDocument.create("uri", "javascript", 1, initialFile), + )!, + [], + ); + + test("newly parsed file", () => { + expect(initialUpdates).toEqual([ + { + fractionalIndex: "a0", + source: new Source(query1, "uri", { + column: 5, + line: 3, + }), + }, + { + fractionalIndex: "a1", + source: new Source(query2, "uri", { + column: 5, + line: 11, + }), + }, + ]); + }); + + test("edited file", () => { + expect( + handleFilePartUpdates( + extractGraphQLSources( + TextDocument.create("uri", "javascript", 2, editedFile), + )!, + initialUpdates, + ), + ).toEqual([ + { + fractionalIndex: "a0", + source: new Source(query1, "uri", { + column: 5, + line: 5, + }), + }, + { + fractionalIndex: "a1", + source: new Source(query2, "uri", { + column: 5, + line: 13, + }), + }, + ]); + }); + + test("inserted file", () => { + expect( + handleFilePartUpdates( + extractGraphQLSources( + TextDocument.create("uri", "javascript", 2, insertedFile), + )!, + initialUpdates, + ), + ).toEqual([ + { + fractionalIndex: "a0", + source: new Source(query1, "uri", { + column: 5, + line: 5, + }), + }, + { + fractionalIndex: "a0V", + source: new Source(query3, "uri", { + column: 5, + line: 12, + }), + }, + { + fractionalIndex: "a1", + source: new Source(query2, "uri", { + column: 5, + line: 19, + }), + }, + ]); + }); + + test("pushed file", () => { + expect( + handleFilePartUpdates( + extractGraphQLSources( + TextDocument.create("uri", "javascript", 2, pushedFile), + )!, + initialUpdates, + ), + ).toEqual([ + { + fractionalIndex: "a0", + source: new Source(query1, "uri", { + column: 5, + line: 5, + }), + }, + { + fractionalIndex: "a1", + source: new Source(query2, "uri", { + column: 5, + line: 12, + }), + }, + { + fractionalIndex: "a2", + source: new Source(query3, "uri", { + column: 5, + line: 19, + }), + }, + ]); + }); + + test("shifted file", () => { + expect( + handleFilePartUpdates( + extractGraphQLSources( + TextDocument.create("uri", "javascript", 2, shiftedFile), + )!, + initialUpdates, + ), + ).toEqual([ + { + fractionalIndex: "a1", + source: new Source(query2, "uri", { + column: 5, + line: 5, + }), + }, + ]); + }); + + test("popped file", () => { + expect( + handleFilePartUpdates( + extractGraphQLSources( + TextDocument.create("uri", "javascript", 2, poppedFile), + )!, + initialUpdates, + ), + ).toEqual([ + { + fractionalIndex: "a0", + source: new Source(query1, "uri", { + column: 5, + line: 3, + }), + }, + ]); + }); +}); diff --git a/src/language-server/project/rover/project.ts b/src/language-server/project/rover/project.ts new file mode 100644 index 00000000..88834b12 --- /dev/null +++ b/src/language-server/project/rover/project.ts @@ -0,0 +1,190 @@ +import { ProjectStats } from "src/messages"; +import { DocumentUri, GraphQLProject } from "../base"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { generateKeyBetween } from "fractional-indexing"; +import { + CancellationToken, + SymbolInformation, + InitializeRequest, + StreamMessageReader, + StreamMessageWriter, + createProtocolConnection, + ProtocolConnection, + ClientCapabilities, + ProtocolRequestType, + CompletionRequest, + ProtocolNotificationType, + DidChangeWatchedFilesNotification, + HoverRequest, + CancellationTokenSource, +} from "vscode-languageserver/node"; +import cp from "node:child_process"; +import { GraphQLProjectConfig } from "../base"; +import { ApolloConfig, RoverConfig } from "../../config"; +import { DocumentSynchronization } from "./DocumentSynchronization"; + +export function isRoverConfig(config: ApolloConfig): config is RoverConfig { + return config instanceof RoverConfig; +} + +export interface RoverProjectConfig extends GraphQLProjectConfig { + config: RoverConfig; + capabilities: ClientCapabilities; +} + +export class RoverProject extends GraphQLProject { + config: RoverConfig; + _connection?: Promise; + capabilities: ClientCapabilities; + get displayName(): string { + return "Rover Project"; + } + private documents = new DocumentSynchronization( + this.sendNotification.bind(this), + ); + + constructor(options: RoverProjectConfig) { + super(options); + this.config = options.config; + this.capabilities = options.capabilities; + } + + initialize() { + return [this.connection.then(() => {})]; + } + + get connection(): Promise { + if (this._connection instanceof Promise) { + return this._connection; + } + return (this._connection = this.initializeConnection()); + } + + private async sendNotification( + type: ProtocolNotificationType, + params?: P, + ): Promise { + const connection = await this.connection; + console.log("sending notification", { type, params }); + return connection.sendNotification(type, params); + } + + private async sendRequest( + type: ProtocolRequestType, + params: P, + token?: CancellationToken, + ): Promise { + const connection = await this.connection; + console.log("sending request", { type, params }); + return connection + .sendRequest(type, params, token) + .then((result) => { + console.log({ result }); + return result; + }) + .catch((error) => { + console.error({ error }); + throw error; + }); + } + + initializeConnection() { + const child = cp.spawn(this.config.rover.bin, ["lsp"], { + env: { RUST_BACKTRACE: "1" }, + }); + const reader = new StreamMessageReader(child.stdout); + const writer = new StreamMessageWriter(child.stdin); + child.stderr.on("data", (data) => { + console.info("stderr", data.toString()); + }); + const connection = createProtocolConnection(reader, writer); + connection.onClose(() => { + console.log("Connection closed"); + source.cancel(); + this._connection = undefined; + }); + + connection.onError((err) => { + console.error({ err }); + }); + + connection.listen(); + console.log("Initializing connection"); + + const source = new CancellationTokenSource(); + return connection + .sendRequest( + InitializeRequest.type, + { + capabilities: this.capabilities, + processId: process.pid, + rootUri: this.rootURI.toString(), + }, + source.token, + ) + .then( + (status) => { + console.log("Connection initialized", status); + return connection; + }, + (error) => { + console.error("Connection failed to initialize", error); + throw error; + }, + ); + } + + getProjectStats(): ProjectStats { + return { type: "Rover", loaded: true }; + } + + includesFile(uri: DocumentUri) { + return uri.startsWith(this.rootURI.toString()); + } + + validate?: () => void; + + onDidChangeWatchedFiles: GraphQLProject["onDidChangeWatchedFiles"] = ( + params, + ) => { + console.log("onDidChangeWatchedFiles", params); + return this.sendNotification( + DidChangeWatchedFilesNotification.type, + params, + ); + }; + + onDidOpen: GraphQLProject["onDidOpen"] = (params) => + this.documents.onDidOpenTextDocument(params); + onDidClose: GraphQLProject["onDidClose"] = (params) => + this.documents.onDidCloseTextDocument(params); + + async documentDidChange(document: TextDocument) { + return this.documents.documentDidChange(document); + } + + // TODO: diagnostics handling in general + clearAllDiagnostics() {} + + onCompletion: GraphQLProject["onCompletion"] = async (params, token) => + this.documents.insideVirtualDocument(params, (virtualParams) => + this.sendRequest(CompletionRequest.type, virtualParams, token), + ); + + onHover: GraphQLProject["onHover"] = async (params, token) => + this.documents.insideVirtualDocument(params, (virtualParams) => + this.sendRequest(HoverRequest.type, virtualParams, token), + ); + + // these are not supported yet + onDefinition: GraphQLProject["onDefinition"]; + onReferences: GraphQLProject["onReferences"]; + onDocumentSymbol: GraphQLProject["onDocumentSymbol"]; + onCodeLens: GraphQLProject["onCodeLens"]; + onCodeAction: GraphQLProject["onCodeAction"]; + + provideSymbol?( + query: string, + token: CancellationToken, + ): Promise; +} diff --git a/src/language-server/server.ts b/src/language-server/server.ts index 01913b8f..8445a6e1 100644 --- a/src/language-server/server.ts +++ b/src/language-server/server.ts @@ -7,6 +7,7 @@ import { ServerCapabilities, TextDocumentSyncKind, SymbolInformation, + FileEvent, } from "vscode-languageserver/node"; import { TextDocument } from "vscode-languageserver-textdocument"; import type { QuickPickItem } from "vscode"; @@ -20,6 +21,8 @@ import { LanguageServerRequests as Requests, } from "../messages"; import { isValidationError } from "zod-validation-error"; +import { Trie } from "@wry/trie"; +import { GraphQLProject } from "./project/base"; const connection = createConnection(ProposedFeatures.all); @@ -85,6 +88,7 @@ connection.onInitialize(async ({ capabilities, workspaceFolders }) => { hasWorkspaceFolderCapability = !!( capabilities.workspace && capabilities.workspace.workspaceFolders ); + workspace.capabilities = capabilities; if (workspaceFolders) { // We wait until all projects are added, because after `initialize` returns we can get additional requests @@ -142,21 +146,30 @@ function isFile(uri: string) { return URI.parse(uri).scheme === "file"; } -documents.onDidChangeContent( - debounceHandler((params) => { - const project = workspace.projectForFile(params.document.uri); - if (!project) return; +documents.onDidChangeContent((params) => { + const project = workspace.projectForFile(params.document.uri); + if (!project) return; - // Only watch changes to files - if (!isFile(params.document.uri)) { - return; - } + // Only watch changes to files + if (!isFile(params.document.uri)) { + return; + } + + project.documentDidChange(params.document); +}); - project.documentDidChange(params.document); - }), +documents.onDidOpen( + (params) => + workspace.projectForFile(params.document.uri)?.onDidOpen?.(params), +); + +documents.onDidClose( + (params) => + workspace.projectForFile(params.document.uri)?.onDidClose?.(params), ); connection.onDidChangeWatchedFiles((params) => { + const handledByProject = new Map(); for (const { uri, type } of params.changes) { if ( uri.endsWith("apollo.config.js") || @@ -182,49 +195,41 @@ connection.onDidChangeWatchedFiles((params) => { const project = workspace.projectForFile(uri); if (!project) continue; - switch (type) { - case FileChangeType.Created: - project.fileDidChange(uri); - break; - case FileChangeType.Deleted: - project.fileWasDeleted(uri); - break; - } + handledByProject.set(project, handledByProject.get(project) || []); + handledByProject.get(project)!.push({ uri, type }); + } + for (const [project, changes] of handledByProject) { + project.onDidChangeWatchedFiles({ changes }); } }); connection.onHover( - (params, token) => + (params, token, workDoneProgress, resultProgress) => workspace .projectForFile(params.textDocument.uri) - ?.provideHover?.(params.textDocument.uri, params.position, token) ?? null, + ?.onHover?.(params, token, workDoneProgress, resultProgress) ?? null, ); connection.onDefinition( - (params, token) => + (params, token, workDoneProgress, resultProgress) => workspace .projectForFile(params.textDocument.uri) - ?.provideDefinition?.(params.textDocument.uri, params.position, token) ?? - null, + ?.onDefinition?.(params, token, workDoneProgress, resultProgress) ?? null, ); connection.onReferences( - (params, token) => + (params, token, workDoneProgress, resultProgress) => workspace .projectForFile(params.textDocument.uri) - ?.provideReferences?.( - params.textDocument.uri, - params.position, - params.context, - token, - ) ?? null, + ?.onReferences?.(params, token, workDoneProgress, resultProgress) ?? null, ); connection.onDocumentSymbol( - (params, token) => + (params, token, workDoneProgress, resultProgress) => workspace .projectForFile(params.textDocument.uri) - ?.provideDocumentSymbol?.(params.textDocument.uri, token) ?? [], + ?.onDocumentSymbol?.(params, token, workDoneProgress, resultProgress) ?? + [], ); connection.onWorkspaceSymbol(async (params, token) => { @@ -241,33 +246,28 @@ connection.onWorkspaceSymbol(async (params, token) => { connection.onCompletion( debounceHandler( - (params, token) => + (params, token, workDoneProgress, resultProgress) => workspace .projectForFile(params.textDocument.uri) - ?.provideCompletionItems?.( - params.textDocument.uri, - params.position, - token, - ) ?? [], + ?.onCompletion?.(params, token, workDoneProgress, resultProgress) ?? [], ), ); connection.onCodeLens( debounceHandler( - (params, token) => + (params, token, workDoneProgress, resultProgress) => workspace .projectForFile(params.textDocument.uri) - ?.provideCodeLenses?.(params.textDocument.uri, token) ?? [], + ?.onCodeLens?.(params, token, workDoneProgress, resultProgress) ?? [], ), ); connection.onCodeAction( debounceHandler( - (params, token) => + (params, token, workDoneProgress, resultProgress) => workspace .projectForFile(params.textDocument.uri) - ?.provideCodeAction?.(params.textDocument.uri, params.range, token) ?? - [], + ?.onCodeAction?.(params, token, workDoneProgress, resultProgress) ?? [], ), ); diff --git a/src/language-server/utilities/__tests__/source.test.ts b/src/language-server/utilities/__tests__/source.test.ts new file mode 100644 index 00000000..e3fe95bc --- /dev/null +++ b/src/language-server/utilities/__tests__/source.test.ts @@ -0,0 +1,116 @@ +import { extractGraphQLSources } from "../../document"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { + findContainedSourceAndPosition, + positionFromPositionInContainingDocument, +} from "../source"; +import { handleFilePartUpdates } from "../../project/rover/DocumentSynchronization"; + +const testText = `import gql from "graphql-tag"; + +const foo = 1 + +gql\` + query Test { + droid(id: "2000") { + name + } + +}\`; + +const verylonglala = gql\`type Foo { baaaaaar: String }\` +`; +describe("positionFromPositionInContainingDocument", () => { + const sources = extractGraphQLSources( + TextDocument.create("uri", "javascript", 1, testText), + "gql", + )!; + + test("should return the correct position inside a document", () => { + expect( + positionFromPositionInContainingDocument(sources[0], { + line: 5, + character: 3, + }), + ).toEqual({ line: 1, character: 3 }); + }); + + test("should return the correct position on the first line of a document", () => { + expect( + positionFromPositionInContainingDocument(sources[0], { + line: 4, + character: 4, + }), + ).toEqual({ line: 0, character: 0 }); + }); + + test("should return the correct position on a single line document", () => { + expect( + positionFromPositionInContainingDocument(sources[1], { + line: 12, + character: 46, + }), + ).toEqual({ line: 0, character: 21 }); + }); +}); + +describe("findContainedSourceAndPosition", () => { + const parts = handleFilePartUpdates( + extractGraphQLSources( + TextDocument.create("uri", "javascript", 1, testText), + "gql", + )!, + [], + ); + + test("should return the correct position inside a document", () => { + expect( + findContainedSourceAndPosition(parts, { + line: 5, + character: 3, + }), + ).toEqual({ ...parts[0], position: { line: 1, character: 3 } }); + }); + + test("should return the correct position on the first line of a document", () => { + expect( + findContainedSourceAndPosition(parts, { + line: 4, + character: 4, + }), + ).toEqual({ ...parts[0], position: { line: 0, character: 0 } }); + }); + + test("should return the correct position on the last line of a document", () => { + expect( + findContainedSourceAndPosition(parts, { + line: 10, + character: 0, + }), + ).toEqual({ ...parts[0], position: { line: 6, character: 0 } }); + }); + + test("should return null if the position is outside of the document", () => { + expect( + findContainedSourceAndPosition(parts, { + line: 4, + character: 3, + }), + ).toBeNull(); + expect( + findContainedSourceAndPosition(parts, { + line: 10, + character: 1, + }), + ).toBeNull(); + }); + + test("should return the correct position on a single line document", () => { + expect( + findContainedSourceAndPosition(parts, { + line: 12, + character: 46, + }), + ).toEqual({ ...parts[1], position: { line: 0, character: 21 } }); + }); +}); diff --git a/src/language-server/utilities/source.ts b/src/language-server/utilities/source.ts index 6cdbdfc3..99f01006 100644 --- a/src/language-server/utilities/source.ts +++ b/src/language-server/utilities/source.ts @@ -65,14 +65,48 @@ export function visitWithTypeInfo( }; } +export function findContainedSourceAndPosition( + parts: T[], + absolutePosition: Position, +) { + for (const part of parts) { + const lines = part.source.body.split("\n"); + const position = positionFromPositionInContainingDocument( + part.source, + absolutePosition, + ); + + // we are in a sub-document that's beyond the position we're looking for + // exit early to save on computing time + if (position.line < 0) return null; + + if ( + position.line >= 0 && + position.line < lines.length && + position.character >= 0 && + (position.line < lines.length - 1 || + position.character < lines[position.line].length) + ) { + return { + ...part, + position, + }; + } + } + return null; +} + export function positionFromPositionInContainingDocument( source: Source, position: Position, ) { if (!source.locationOffset) return position; + const line = position.line - (source.locationOffset.line - 1); return Position.create( - position.line - (source.locationOffset.line - 1), - position.character, + line, + line === 0 + ? position.character - (source.locationOffset.column - 1) + : position.character, ); } diff --git a/src/language-server/workspace.ts b/src/language-server/workspace.ts index 9413ce19..ee6d853f 100644 --- a/src/language-server/workspace.ts +++ b/src/language-server/workspace.ts @@ -2,6 +2,7 @@ import { WorkspaceFolder, NotificationHandler, PublishDiagnosticsParams, + ClientCapabilities, } from "vscode-languageserver/node"; import { QuickPickItem } from "vscode"; import { GraphQLProject, DocumentUri } from "./project/base"; @@ -15,6 +16,7 @@ import { URI } from "vscode-uri"; import { Debug } from "./utilities"; import type { EngineDecoration } from "../messages"; import { equal } from "@wry/equality"; +import { isRoverConfig, RoverProject } from "./project/rover/project"; export interface WorkspaceConfig { clientIdentity: ClientIdentity; @@ -26,6 +28,7 @@ export class GraphQLWorkspace { private _onSchemaTags?: NotificationHandler<[ServiceID, SchemaTag[]]>; private _onConfigFilesFound?: NotificationHandler<(ApolloConfig | Error)[]>; private _projectForFileCache: Map = new Map(); + public capabilities?: ClientCapabilities; private projectsByFolderUri: Map = new Map(); @@ -65,8 +68,15 @@ export class GraphQLWorkspace { configFolderURI: URI.parse(folder.uri), clientIdentity, }) + : isRoverConfig(config) + ? new RoverProject({ + config, + loadingHandler: this.LanguageServerLoadingHandler, + configFolderURI: URI.parse(folder.uri), + capabilities: this.capabilities!, // TODO? + }) : (() => { - throw new Error("TODO rover project"); + throw new Error("Impossible config!"); })(); project.onDiagnostics((params) => { @@ -86,7 +96,7 @@ export class GraphQLWorkspace { // after a project has loaded, we do an initial validation to surface errors // on the start of the language server. Instead of doing this in the // base class which is used by codegen and other tools - project.whenReady.then(() => project.validate()); + project.whenReady.then(() => project.validate?.()); return project; }