vscode-apollo 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@
17
17
  "env": {
18
18
  "APOLLO_ENGINE_ENDPOINT": "http://localhost:7096/apollo",
19
19
  "APOLLO_FEATURE_FLAGS": "rover"
20
+ //"APOLLO_ROVER_LANGUAGE_IDS": "graphql,javascript"
20
21
  },
21
22
  "outFiles": ["${workspaceRoot}/lib/**/*.js"]
22
23
  },
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 2.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#184](https://github.com/apollographql/vscode-graphql/pull/184) [`9c53a11e`](https://github.com/apollographql/vscode-graphql/commit/9c53a11e3006dd69675af976ef3857212d8f9f43) Thanks [@phryneas](https://github.com/phryneas)! - Derive extensions for supported languages and monitored files from other installed extensions.
8
+ Adjust default `includes` for client projects.
9
+
10
+ This changes the default `includes` similar to (depending on additional extensions you might have installed):
11
+
12
+ ```diff
13
+ -'src/**/*.{ts,tsx,js,jsx,graphql,gql}',
14
+ +'src/**/*{.gql,.graphql,.graphqls,.js,.mjs,.cjs,.es6,.pac,.ts,.mts,.cts,.jsx,.tsx,.vue,.svelte,.py,.rpy,.pyw,.cpy,.gyp,.gypi,.pyi,.ipy,.pyt,.rb,.rbx,.rjs,.gemspec,.rake,.ru,.erb,.podspec,.rbi,.dart,.re,.ex,.exs}'
15
+ ```
16
+
3
17
  ## 2.1.0
4
18
 
5
19
  ### Minor Changes
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "vscode-apollo",
3
3
  "displayName": "Apollo GraphQL",
4
4
  "description": "Rich editor support for GraphQL client and server development that seamlessly integrates with the Apollo platform",
5
- "version": "2.1.0",
5
+ "version": "2.2.0",
6
6
  "referenceID": "87197759-7617-40d0-b32e-46d378e907c7",
7
7
  "author": "Apollo GraphQL <opensource@apollographql.com>",
8
8
  "license": "MIT",
@@ -40,7 +40,6 @@
40
40
  "@apollo/client": "3.11.4",
41
41
  "@apollo/subgraph": "2.8.4",
42
42
  "@graphql-tools/schema": "10.0.5",
43
- "@wry/context": "0.7.4",
44
43
  "@wry/equality": "0.5.7",
45
44
  "cosmiconfig": "9.0.0",
46
45
  "dotenv": "16.4.5",
@@ -99,9 +99,6 @@ Object {
99
99
  "**/node_modules",
100
100
  "**/__tests__",
101
101
  ],
102
- "includes": Array [
103
- "src/**/*.{ts,tsx,js,jsx,graphql,gql}",
104
- ],
105
102
  "service": "hello",
106
103
  "tagName": "gql",
107
104
  },
@@ -137,6 +134,7 @@ Object {
137
134
  },
138
135
  "rover": Object {
139
136
  "bin": "${dir}/bin/rover",
137
+ "extraArgs": Array [],
140
138
  },
141
139
  }
142
140
  `);
@@ -395,9 +393,14 @@ Object {
395
393
  configPath: dirPath,
396
394
  });
397
395
 
398
- expect((config?.rawConfig as any).client.includes).toEqual([
399
- "src/**/*.{ts,tsx,js,jsx,graphql,gql}",
400
- ]);
396
+ expect((config?.rawConfig as any).client.includes).toEqual(
397
+ /**
398
+ * This will be calculated in the `GraphQLInternalProject` constructor by calling `getSupportedExtensions()`
399
+ * which will have information about all the extensions added by other VSCode extensions for the language ids
400
+ * that Apollo supports.
401
+ */
402
+ undefined,
403
+ );
401
404
  });
402
405
 
403
406
  it("merges engine config defaults", async () => {
@@ -1,13 +1,14 @@
1
- import { dirname } from "path";
1
+ import { dirname, join } from "path";
2
2
  import { URI } from "vscode-uri";
3
3
  import { getGraphIdFromConfig, parseServiceSpecifier } from "./utils";
4
4
  import { Debug } from "../utilities";
5
5
  import z, { ZodError } from "zod";
6
6
  import { ValidationRule } from "graphql/validation/ValidationContext";
7
- import { Slot } from "@wry/context";
8
7
  import { fromZodError } from "zod-validation-error";
9
8
  import which from "which";
10
9
  import { accessSync, constants as fsConstants, statSync } from "node:fs";
10
+ import { AsyncLocalStorage } from "async_hooks";
11
+ import { existsSync } from "fs";
11
12
 
12
13
  const ROVER_AVAILABLE = (process.env.APOLLO_FEATURE_FLAGS || "")
13
14
  .split(",")
@@ -29,8 +30,9 @@ function ignoredFieldWarning(
29
30
  export interface Context {
30
31
  apiKey?: string;
31
32
  serviceName?: string;
33
+ configPath?: string;
32
34
  }
33
- const context = new Slot<Context>();
35
+ const contextStore = new AsyncLocalStorage<Context>();
34
36
 
35
37
  const studioServiceConfig = z.string();
36
38
 
@@ -49,7 +51,7 @@ const localServiceConfig = z.object({
49
51
  export type LocalServiceConfig = z.infer<typeof localServiceConfig>;
50
52
 
51
53
  const clientServiceConfig = z.preprocess(
52
- (value) => value || context.getValue()?.serviceName,
54
+ (value) => value || contextStore.getStore()?.serviceName,
53
55
  z.union([studioServiceConfig, remoteServiceConfig, localServiceConfig]),
54
56
  );
55
57
  export type ClientServiceConfig = z.infer<typeof clientServiceConfig>;
@@ -63,9 +65,7 @@ const clientConfig = z.object({
63
65
  ])
64
66
  .optional(),
65
67
  // maybe shared with rover?
66
- includes: z
67
- .array(z.string())
68
- .default(["src/**/*.{ts,tsx,js,jsx,graphql,gql}"]),
68
+ includes: z.array(z.string()).optional(),
69
69
  // maybe shared with rover?
70
70
  excludes: z.array(z.string()).default(["**/node_modules", "**/__tests__"]),
71
71
  // maybe shared with rover?
@@ -106,6 +106,21 @@ const roverConfig = z.object({
106
106
  },
107
107
  ),
108
108
  profile: z.string().optional(),
109
+ supergraphConfig: z
110
+ .preprocess((value) => {
111
+ if (value !== undefined) return value;
112
+ const configPath = contextStore.getStore()?.configPath!;
113
+ const supergraphConfig = join(configPath, "supergraph.yml");
114
+ return existsSync(supergraphConfig) ? supergraphConfig : undefined;
115
+ }, z.string().nullable().optional())
116
+ .describe(
117
+ "The path to your `supergraph.yml` file. \n" +
118
+ "Defaults to a `supergraph.yml` in the folder of your `apollo.config.js`, if there is one.",
119
+ ),
120
+ extraArgs: z
121
+ .array(z.string())
122
+ .default([])
123
+ .describe("Extra arguments to pass to the Rover CLI."),
109
124
  });
110
125
  type RoverConfigFormat = z.infer<typeof roverConfig>;
111
126
 
@@ -117,7 +132,7 @@ const engineConfig = z.object({
117
132
  "https://graphql.api.apollographql.com/api/graphql",
118
133
  ),
119
134
  apiKey: z.preprocess(
120
- (val) => val || context.getValue()?.apiKey,
135
+ (val) => val || contextStore.getStore()?.apiKey,
121
136
  z.string().optional(),
122
137
  ),
123
138
  });
@@ -202,9 +217,7 @@ export function parseApolloConfig(
202
217
  configURI?: URI,
203
218
  ctx: Context = {},
204
219
  ) {
205
- const parsed = context.withValue(ctx, () =>
206
- configSchema.safeParse(rawConfig),
207
- );
220
+ const parsed = contextStore.run(ctx, () => configSchema.safeParse(rawConfig));
208
221
  if (!parsed.success) {
209
222
  // Remove "or Required at rover" errors when a client config is provided
210
223
  // Remove "or Required at client" errors when a rover config is provided
@@ -1,5 +1,5 @@
1
1
  import { cosmiconfig, defaultLoaders } from "cosmiconfig";
2
- import { resolve } from "path";
2
+ import { dirname, resolve } from "path";
3
3
  import { readFileSync, existsSync, lstatSync } from "fs";
4
4
  import {
5
5
  ApolloConfig,
@@ -103,5 +103,6 @@ export async function loadConfig({
103
103
  return parseApolloConfig(config, URI.file(resolve(filepath)), {
104
104
  apiKey,
105
105
  serviceName: nameFromKey,
106
+ configPath: dirname(filepath),
106
107
  });
107
108
  }
@@ -14,12 +14,10 @@ export class FileSet {
14
14
  rootURI,
15
15
  includes,
16
16
  excludes,
17
- configURI,
18
17
  }: {
19
18
  rootURI: URI;
20
19
  includes: string[];
21
20
  excludes: string[];
22
- configURI?: URI;
23
21
  }) {
24
22
  invariant(rootURI, `Must provide "rootURI".`);
25
23
  invariant(includes, `Must provide "includes".`);
@@ -30,13 +28,6 @@ export class FileSet {
30
28
  this.excludes = excludes;
31
29
  }
32
30
 
33
- pushIncludes(files: string[]) {
34
- this.includes.push(...files);
35
- }
36
- pushExcludes(files: string[]) {
37
- this.excludes.push(...files);
38
- }
39
-
40
31
  includesFile(filePath: string): boolean {
41
32
  const normalizedFilePath = normalizeURI(filePath);
42
33
 
@@ -57,10 +48,14 @@ export class FileSet {
57
48
  }
58
49
 
59
50
  allFiles(): string[] {
60
- // since glob.sync takes a single pattern, but we allow an array of `includes`, we can join all the
61
- // `includes` globs into a single pattern and pass to glob.sync. The `ignore` option does, however, allow
62
- // an array of globs to ignore, so we can pass it in directly
63
- const joinedIncludes = `{${this.includes.join(",")}}`;
51
+ const joinedIncludes =
52
+ this.includes.length == 1
53
+ ? this.includes[0]
54
+ : // since glob.sync takes a single pattern, but we allow an array of `includes`, we can join all the
55
+ // `includes` globs into a single pattern and pass to glob.sync. The `ignore` option does, however, allow
56
+ // an array of globs to ignore, so we can pass it in directly
57
+ `{${this.includes.join(",")}}`;
58
+
64
59
  return globSync(joinedIncludes, {
65
60
  cwd: this.rootURI.fsPath,
66
61
  absolute: true,
@@ -12,6 +12,7 @@ import {
12
12
  TextDocumentChangeEvent,
13
13
  StarRequestHandler,
14
14
  StarNotificationHandler,
15
+ ServerCapabilities,
15
16
  } from "vscode-languageserver/node";
16
17
  import { TextDocument } from "vscode-languageserver-textdocument";
17
18
 
@@ -46,12 +47,13 @@ export abstract class GraphQLProject {
46
47
  private readyPromise: Promise<void>;
47
48
  public config: ApolloConfig;
48
49
  protected schema?: GraphQLSchema;
49
- protected fileSet: FileSet;
50
50
  protected rootURI: URI;
51
51
  protected loadingHandler: LoadingHandler;
52
52
 
53
53
  protected lastLoadDate?: number;
54
54
 
55
+ private configFileSet: FileSet;
56
+
55
57
  constructor({
56
58
  config,
57
59
  configFolderURI,
@@ -63,7 +65,7 @@ export abstract class GraphQLProject {
63
65
  // if a config doesn't have a uri associated, we can assume the `rootURI` is the project's root.
64
66
  this.rootURI = config.configDirURI || configFolderURI;
65
67
 
66
- this.fileSet = new FileSet({
68
+ this.configFileSet = new FileSet({
67
69
  rootURI: this.rootURI,
68
70
  includes: [
69
71
  ".env",
@@ -73,7 +75,6 @@ export abstract class GraphQLProject {
73
75
  "apollo.config.ts",
74
76
  ],
75
77
  excludes: [],
76
- configURI: config.configURI,
77
78
  });
78
79
 
79
80
  this._isReady = false;
@@ -117,32 +118,38 @@ export abstract class GraphQLProject {
117
118
  this._onDiagnostics = handler;
118
119
  }
119
120
 
120
- abstract includesFile(uri: DocumentUri): boolean;
121
+ abstract includesFile(uri: DocumentUri, languageId?: string): boolean;
122
+ isConfiguredBy(uri: DocumentUri): boolean {
123
+ return this.configFileSet.includesFile(uri);
124
+ }
121
125
 
122
126
  abstract onDidChangeWatchedFiles: ConnectionHandler["onDidChangeWatchedFiles"];
123
- abstract onDidOpen?: (event: TextDocumentChangeEvent<TextDocument>) => void;
124
- abstract onDidClose?: (event: TextDocumentChangeEvent<TextDocument>) => void;
127
+ onDidOpen?: (event: TextDocumentChangeEvent<TextDocument>) => void;
128
+ onDidClose?: (event: TextDocumentChangeEvent<TextDocument>) => void;
125
129
  abstract documentDidChange(document: TextDocument): void;
126
130
  abstract clearAllDiagnostics(): void;
127
131
 
128
- abstract onCompletion?: ConnectionHandler["onCompletion"];
129
- abstract onHover?: ConnectionHandler["onHover"];
130
- abstract onDefinition?: ConnectionHandler["onDefinition"];
131
- abstract onReferences?: ConnectionHandler["onReferences"];
132
- abstract onDocumentSymbol?: ConnectionHandler["onDocumentSymbol"];
133
- abstract onCodeLens?: ConnectionHandler["onCodeLens"];
134
- abstract onCodeAction?: ConnectionHandler["onCodeAction"];
132
+ onCompletion?: ConnectionHandler["onCompletion"];
133
+ onHover?: ConnectionHandler["onHover"];
134
+ onDefinition?: ConnectionHandler["onDefinition"];
135
+ onReferences?: ConnectionHandler["onReferences"];
136
+ onDocumentSymbol?: ConnectionHandler["onDocumentSymbol"];
137
+ onCodeLens?: ConnectionHandler["onCodeLens"];
138
+ onCodeAction?: ConnectionHandler["onCodeAction"];
135
139
 
136
- abstract onUnhandledRequest?: StarRequestHandler;
137
- abstract onUnhandledNotification?: (
140
+ onUnhandledRequest?: StarRequestHandler;
141
+ onUnhandledNotification?: (
138
142
  connection: Connection,
139
143
  ...rest: Parameters<StarNotificationHandler>
140
144
  ) => ReturnType<StarNotificationHandler>;
141
145
 
142
- abstract dispose?(): void;
146
+ dispose?(): void;
143
147
 
144
- abstract provideSymbol?(
148
+ provideSymbol?(
145
149
  query: string,
146
150
  token: CancellationToken,
147
151
  ): Promise<SymbolInformation[]>;
152
+
153
+ onVSCodeConnectionInitialized?(connection: Connection): void;
154
+ validate?(): void;
148
155
  }
@@ -201,20 +201,7 @@ export class GraphQLClientProject extends GraphQLInternalProject {
201
201
  super({ config, configFolderURI, loadingHandler, clientIdentity });
202
202
  this.serviceID = config.graph;
203
203
 
204
- /**
205
- * This function is used in the Array.filter function below it to remove any .env files and config files.
206
- * If there are 0 files remaining after removing those files, we should warn the user that their config
207
- * may be wrong. We shouldn't throw an error here, since they could just be initially setting up a project
208
- * and there's no way to know for sure that there _should_ be files.
209
- */
210
- const filterConfigAndEnvFiles = (path: string) =>
211
- !(
212
- path.includes("apollo.config") ||
213
- path.includes(".env") ||
214
- (config.configURI && path === config.configURI.fsPath)
215
- );
216
-
217
- if (this.allIncludedFiles().filter(filterConfigAndEnvFiles).length === 0) {
204
+ if (this.allIncludedFiles().length === 0) {
218
205
  console.warn(
219
206
  "⚠️ It looks like there are 0 files associated with this Apollo Project. " +
220
207
  "This may be because you don't have any files yet, or your includes/excludes " +
@@ -30,6 +30,8 @@ import {
30
30
  import { ApolloEngineClient, ClientIdentity } from "../engine";
31
31
  import { GraphQLProject, DocumentUri, GraphQLProjectConfig } from "./base";
32
32
  import throttle from "lodash.throttle";
33
+ import { FileSet } from "../fileSet";
34
+ import { getSupportedExtensions } from "../utilities/languageIdForExtension";
33
35
 
34
36
  const fileAssociations: { [extension: string]: string } = {
35
37
  ".graphql": "graphql",
@@ -58,6 +60,7 @@ export abstract class GraphQLInternalProject
58
60
  {
59
61
  public schemaProvider: GraphQLSchemaProvider;
60
62
  protected engineClient?: ApolloEngineClient;
63
+ private fileSet: FileSet;
61
64
 
62
65
  private needsValidation = false;
63
66
 
@@ -70,16 +73,23 @@ export abstract class GraphQLInternalProject
70
73
  clientIdentity,
71
74
  }: GraphQLInternalProjectConfig) {
72
75
  super({ config, configFolderURI, loadingHandler });
73
- const { includes = [], excludes = [] } = config.client;
76
+ const {
77
+ // something like
78
+ // 'src/**/*{.gql,.graphql,.graphqls,.js,.mjs,.cjs,.es6,.pac,.ts,.mts,.cts,.jsx,.tsx,.vue,.svelte,.py,.rpy,.pyw,.cpy,.gyp,.gypi,.pyi,.ipy,.pyt,.rb,.rbx,.rjs,.gemspec,.rake,.ru,.erb,.podspec,.rbi,.dart,.re,.ex,.exs}'
79
+ includes = [`src/**/*{${getSupportedExtensions().join(",")}}`],
80
+ excludes = [],
81
+ } = config.client;
74
82
 
75
83
  this.documentsByFile = new Map();
76
-
77
- this.fileSet.pushIncludes(includes);
78
- // We do not want to include the local schema file in our list of documents
79
- this.fileSet.pushExcludes([
80
- ...excludes,
81
- ...this.getRelativeLocalSchemaFilePaths(),
82
- ]);
84
+ this.fileSet = new FileSet({
85
+ rootURI: this.rootURI,
86
+ includes,
87
+ excludes: [
88
+ ...excludes,
89
+ // We do not want to include the local schema file in our list of documents
90
+ ...this.getRelativeLocalSchemaFilePaths(),
91
+ ],
92
+ });
83
93
 
84
94
  this.schemaProvider = schemaProviderFromConfig(config, clientIdentity);
85
95
  const { engine } = config;
@@ -327,8 +337,6 @@ export abstract class GraphQLInternalProject
327
337
  }
328
338
  return definitionsAndExtensions;
329
339
  }
330
- onDidOpen: undefined;
331
- onDidClose: undefined;
332
340
  onDidChangeWatchedFiles: GraphQLProject["onDidChangeWatchedFiles"] = (
333
341
  params,
334
342
  ) => {
@@ -343,7 +351,4 @@ export abstract class GraphQLInternalProject
343
351
  }
344
352
  }
345
353
  };
346
- onUnhandledRequest: undefined;
347
- onUnhandledNotification: undefined;
348
- dispose: undefined;
349
354
  }
@@ -8,6 +8,11 @@ import {
8
8
  Diagnostic,
9
9
  NotificationHandler,
10
10
  PublishDiagnosticsParams,
11
+ SemanticTokensRequest,
12
+ ProtocolRequestType,
13
+ SemanticTokensParams,
14
+ SemanticTokens,
15
+ CancellationToken,
11
16
  } from "vscode-languageserver-protocol";
12
17
  import { TextDocument } from "vscode-languageserver-textdocument";
13
18
  import { DocumentUri, GraphQLProject } from "../base";
@@ -105,6 +110,11 @@ export class DocumentSynchronization {
105
110
  type: ProtocolNotificationType<P, RO>,
106
111
  params?: P,
107
112
  ) => Promise<void>,
113
+ private sendRequest: <P, R, PR, E, RO>(
114
+ type: ProtocolRequestType<P, R, PR, E, RO>,
115
+ params: P,
116
+ token?: CancellationToken,
117
+ ) => Promise<R>,
108
118
  private sendDiagnostics: NotificationHandler<PublishDiagnosticsParams>,
109
119
  ) {}
110
120
 
@@ -305,4 +315,71 @@ export class DocumentSynchronization {
305
315
  this.sendDiagnostics({ uri: file.full.uri, diagnostics: [] });
306
316
  }
307
317
  }
318
+
319
+ /**
320
+ * Receives semantic tokens for all sub-documents and glues them together.
321
+ * See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_semanticTokens
322
+ * TLDR: The tokens are a flat array of numbers, where each token is represented by 5 numbers.
323
+ * The first two numbers represent the token's delta line and delta start character and might need adjusing
324
+ * relative to the start of a sub-document in relation to the position of the last token of the previous sub-document.
325
+ *
326
+ * There is also an "incremental" version of this request, but we don't support it yet.
327
+ * This is complicated enough as it is.
328
+ */
329
+ async getFullSemanticTokens(
330
+ params: SemanticTokensParams,
331
+ cancellationToken: CancellationToken,
332
+ ): Promise<SemanticTokens | null> {
333
+ await this.synchronizedWithDocument(params.textDocument.uri);
334
+ const found = this.knownFiles.get(params.textDocument.uri);
335
+ if (!found) {
336
+ return null;
337
+ }
338
+ const allParts = await Promise.all(
339
+ found.parts.map(async (part) => {
340
+ return {
341
+ part,
342
+ tokens: await this.sendRequest(
343
+ SemanticTokensRequest.type,
344
+ {
345
+ textDocument: { uri: getUri(found.full, part) },
346
+ },
347
+ cancellationToken,
348
+ ),
349
+ };
350
+ }),
351
+ );
352
+ let line = 0,
353
+ char = 0,
354
+ lastLine = 0,
355
+ lastChar = 0;
356
+ const combinedTokens = [];
357
+ for (const { part, tokens } of allParts) {
358
+ if (!tokens) {
359
+ continue;
360
+ }
361
+ line = part.source.locationOffset.line - 1;
362
+ char = part.source.locationOffset.column - 1;
363
+ for (let i = 0; i < tokens.data.length; i += 5) {
364
+ const deltaLine = tokens.data[i],
365
+ deltaStartChar = tokens.data[i + 1];
366
+
367
+ // We need to run this loop fully to correctly calculate the `lastLine` and `lastChar`
368
+ // so for the next incoming tokens, we can adjust the delta correctly.
369
+ line = line + deltaLine;
370
+ char = deltaLine === 0 ? char + deltaStartChar : deltaStartChar;
371
+ // we just need to adjust the deltas only for the first token
372
+ if (i === 0) {
373
+ tokens.data[0] = line - lastLine;
374
+ tokens.data[1] = line === lastLine ? lastChar - char : char;
375
+ }
376
+ }
377
+ combinedTokens.push(...tokens.data);
378
+ lastLine = line;
379
+ lastChar = char;
380
+ }
381
+ return {
382
+ data: combinedTokens,
383
+ };
384
+ }
308
385
  }
@@ -3,7 +3,6 @@ import { DocumentUri, GraphQLProject } from "../base";
3
3
  import { TextDocument } from "vscode-languageserver-textdocument";
4
4
  import {
5
5
  CancellationToken,
6
- SymbolInformation,
7
6
  InitializeRequest,
8
7
  StreamMessageReader,
9
8
  StreamMessageWriter,
@@ -19,6 +18,12 @@ import {
19
18
  PublishDiagnosticsNotification,
20
19
  ConnectionError,
21
20
  ConnectionErrors,
21
+ SemanticTokensRequest,
22
+ ProtocolRequestType0,
23
+ ServerCapabilities,
24
+ SemanticTokensRegistrationType,
25
+ SemanticTokensOptions,
26
+ SemanticTokensRegistrationOptions,
22
27
  } from "vscode-languageserver/node";
23
28
  import cp from "node:child_process";
24
29
  import { GraphQLProjectConfig } from "../base";
@@ -26,6 +31,10 @@ import { ApolloConfig, RoverConfig } from "../../config";
26
31
  import { DocumentSynchronization } from "./DocumentSynchronization";
27
32
  import { AsyncLocalStorage } from "node:async_hooks";
28
33
  import internal from "node:stream";
34
+ import { VSCodeConnection } from "../../server";
35
+ import { getLanguageIdForExtension } from "../../utilities/languageIdForExtension";
36
+ import { extname } from "node:path";
37
+ import type { FileExtension } from "../../../tools/utilities/languageInformation";
29
38
 
30
39
  export const DEBUG = true;
31
40
 
@@ -38,6 +47,10 @@ export interface RoverProjectConfig extends GraphQLProjectConfig {
38
47
  capabilities: ClientCapabilities;
39
48
  }
40
49
 
50
+ const supportedLanguageIds = (
51
+ process.env.APOLLO_ROVER_LANGUAGE_IDS || "graphql"
52
+ ).split(",");
53
+
41
54
  export class RoverProject extends GraphQLProject {
42
55
  config: RoverConfig;
43
56
  /**
@@ -52,11 +65,13 @@ export class RoverProject extends GraphQLProject {
52
65
  | undefined;
53
66
  private disposed = false;
54
67
  readonly capabilities: ClientCapabilities;
68
+ roverCapabilities?: ServerCapabilities;
55
69
  get displayName(): string {
56
70
  return "Rover Project";
57
71
  }
58
72
  private documents = new DocumentSynchronization(
59
73
  this.sendNotification.bind(this),
74
+ this.sendRequest.bind(this),
60
75
  (diagnostics) => this._onDiagnostics?.(diagnostics),
61
76
  );
62
77
 
@@ -143,7 +158,18 @@ export class RoverProject extends GraphQLProject {
143
158
  "Connection is closed.",
144
159
  );
145
160
  }
146
- const child = cp.spawn(this.config.rover.bin, ["lsp"], {
161
+ const args = ["lsp", "--elv2-license", "accept"];
162
+ if (this.config.rover.profile) {
163
+ args.push("--profile", this.config.rover.profile);
164
+ }
165
+ if (this.config.rover.supergraphConfig) {
166
+ args.push("--supergraph-config", this.config.rover.supergraphConfig);
167
+ }
168
+ args.push(...this.config.rover.extraArgs);
169
+
170
+ DEBUG &&
171
+ console.log(`starting ${this.config.rover.bin} '${args.join("' '")}'`);
172
+ const child = cp.spawn(this.config.rover.bin, args, {
147
173
  env: DEBUG ? { RUST_BACKTRACE: "1" } : {},
148
174
  stdio: ["pipe", "pipe", DEBUG ? "inherit" : "ignore"],
149
175
  });
@@ -185,6 +211,7 @@ export class RoverProject extends GraphQLProject {
185
211
  },
186
212
  source.token,
187
213
  );
214
+ this.roverCapabilities = status.capabilities;
188
215
  DEBUG && console.log("Connection initialized", status);
189
216
 
190
217
  await this.connectionStorage.run(
@@ -203,12 +230,16 @@ export class RoverProject extends GraphQLProject {
203
230
  return { type: "Rover", loaded: true };
204
231
  }
205
232
 
206
- includesFile(uri: DocumentUri) {
207
- return uri.startsWith(this.rootURI.toString());
233
+ includesFile(
234
+ uri: DocumentUri,
235
+ languageId = getLanguageIdForExtension(extname(uri) as FileExtension),
236
+ ) {
237
+ return (
238
+ uri.startsWith(this.rootURI.toString()) &&
239
+ supportedLanguageIds.includes(languageId)
240
+ );
208
241
  }
209
242
 
210
- validate?: () => void;
211
-
212
243
  onDidChangeWatchedFiles: GraphQLProject["onDidChangeWatchedFiles"] = (
213
244
  params,
214
245
  ) => {
@@ -250,8 +281,17 @@ export class RoverProject extends GraphQLProject {
250
281
  this.sendRequest(HoverRequest.type, virtualParams, token),
251
282
  );
252
283
 
253
- onUnhandledRequest: GraphQLProject["onUnhandledRequest"] = (type, params) => {
254
- DEBUG && console.info("unhandled request from VSCode", { type, params });
284
+ onUnhandledRequest: GraphQLProject["onUnhandledRequest"] = async (
285
+ type,
286
+ params,
287
+ token,
288
+ ) => {
289
+ if (isRequestType(SemanticTokensRequest.type, type, params)) {
290
+ return this.documents.getFullSemanticTokens(params, token);
291
+ } else {
292
+ DEBUG && console.info("unhandled request from VSCode", { type, params });
293
+ return undefined;
294
+ }
255
295
  };
256
296
  onUnhandledNotification: GraphQLProject["onUnhandledNotification"] = (
257
297
  _connection,
@@ -262,15 +302,40 @@ export class RoverProject extends GraphQLProject {
262
302
  console.info("unhandled notification from VSCode", { type, params });
263
303
  };
264
304
 
265
- // these are not supported yet
266
- onDefinition: GraphQLProject["onDefinition"];
267
- onReferences: GraphQLProject["onReferences"];
268
- onDocumentSymbol: GraphQLProject["onDocumentSymbol"];
269
- onCodeLens: GraphQLProject["onCodeLens"];
270
- onCodeAction: GraphQLProject["onCodeAction"];
271
-
272
- provideSymbol?(
273
- query: string,
274
- token: CancellationToken,
275
- ): Promise<SymbolInformation[]>;
305
+ async onVSCodeConnectionInitialized(connection: VSCodeConnection) {
306
+ // Report the actual capabilities of the upstream LSP to VSCode.
307
+ // It is important to actually "ask" the LSP for this, because the capabilities
308
+ // also define the semantic token legend, which is needed to interpret the tokens.
309
+ await this.getConnection();
310
+ const capabilities = this.roverCapabilities;
311
+ if (capabilities?.semanticTokensProvider) {
312
+ connection.client.register(SemanticTokensRegistrationType.type, {
313
+ documentSelector: null,
314
+ ...capabilities.semanticTokensProvider,
315
+ full: {
316
+ // the upstream LSP supports "true" here, but we don't yet
317
+ delta: false,
318
+ },
319
+ });
320
+ }
321
+ }
322
+ }
323
+
324
+ function isRequestType<R, PR, E, RO>(
325
+ type: ProtocolRequestType0<R, PR, E, RO>,
326
+ method: string,
327
+ params: any,
328
+ ): params is PR;
329
+ function isRequestType<P, R, PR, E, RO>(
330
+ type: ProtocolRequestType<P, R, PR, E, RO>,
331
+ method: string,
332
+ params: any,
333
+ ): params is P;
334
+ function isRequestType(
335
+ type:
336
+ | ProtocolRequestType0<any, any, any, any>
337
+ | ProtocolRequestType<any, any, any, any, any>,
338
+ method: string,
339
+ ) {
340
+ return type.method === method;
276
341
  }
@@ -4,7 +4,6 @@ import {
4
4
  ProposedFeatures,
5
5
  TextDocuments,
6
6
  FileChangeType,
7
- ServerCapabilities,
8
7
  TextDocumentSyncKind,
9
8
  SymbolInformation,
10
9
  FileEvent,
@@ -21,10 +20,16 @@ import {
21
20
  LanguageServerRequests as Requests,
22
21
  } from "../messages";
23
22
  import { isValidationError } from "zod-validation-error";
24
- import { Trie } from "@wry/trie";
25
23
  import { GraphQLProject } from "./project/base";
24
+ import type { LanguageIdExtensionMap } from "../tools/utilities/languageInformation";
25
+ import { setLanguageIdExtensionMap } from "./utilities/languageIdForExtension";
26
+
27
+ export type InitializationOptions = {
28
+ languageIdExtensionMap: LanguageIdExtensionMap;
29
+ };
26
30
 
27
31
  const connection = createConnection(ProposedFeatures.all);
32
+ export type VSCodeConnection = typeof connection;
28
33
 
29
34
  Debug.SetConnection(connection);
30
35
  const { sendNotification: originalSendNotification } = connection;
@@ -37,8 +42,8 @@ connection.sendNotification = async (...args: [any, ...any[]]) => {
37
42
  let hasWorkspaceFolderCapability = false;
38
43
 
39
44
  // Awaitable promise for sending messages before the connection is initialized
40
- let initializeConnection: () => void;
41
- const whenConnectionInitialized: Promise<void> = new Promise(
45
+ let initializeConnection: (c: typeof connection) => void;
46
+ const whenConnectionInitialized: Promise<typeof connection> = new Promise(
42
47
  (resolve) => (initializeConnection = resolve),
43
48
  );
44
49
 
@@ -55,6 +60,7 @@ const workspace = new GraphQLWorkspace(
55
60
  require("../../package.json").version,
56
61
  },
57
62
  },
63
+ whenConnectionInitialized,
58
64
  );
59
65
 
60
66
  workspace.onDiagnostics((params) => {
@@ -84,46 +90,52 @@ workspace.onConfigFilesFound(async (params) => {
84
90
  );
85
91
  });
86
92
 
87
- connection.onInitialize(async ({ capabilities, workspaceFolders }) => {
88
- hasWorkspaceFolderCapability = !!(
89
- capabilities.workspace && capabilities.workspace.workspaceFolders
90
- );
91
- workspace.capabilities = capabilities;
92
-
93
- if (workspaceFolders) {
94
- // We wait until all projects are added, because after `initialize` returns we can get additional requests
95
- // like `textDocument/codeLens`, and that way these can await `GraphQLProject#whenReady` to make sure
96
- // we provide them eventually.
97
- await Promise.all(
98
- workspaceFolders.map((folder) => workspace.addProjectsInFolder(folder)),
93
+ connection.onInitialize(
94
+ async ({ capabilities, workspaceFolders, initializationOptions }) => {
95
+ const { languageIdExtensionMap } =
96
+ initializationOptions as InitializationOptions;
97
+ setLanguageIdExtensionMap(languageIdExtensionMap);
98
+
99
+ hasWorkspaceFolderCapability = !!(
100
+ capabilities.workspace && capabilities.workspace.workspaceFolders
99
101
  );
100
- }
102
+ workspace.capabilities = capabilities;
103
+
104
+ if (workspaceFolders) {
105
+ // We wait until all projects are added, because after `initialize` returns we can get additional requests
106
+ // like `textDocument/codeLens`, and that way these can await `GraphQLProject#whenReady` to make sure
107
+ // we provide them eventually.
108
+ await Promise.all(
109
+ workspaceFolders.map((folder) => workspace.addProjectsInFolder(folder)),
110
+ );
111
+ }
101
112
 
102
- return {
103
- capabilities: {
104
- hoverProvider: true,
105
- completionProvider: {
106
- resolveProvider: false,
107
- triggerCharacters: ["...", "@"],
108
- },
109
- definitionProvider: true,
110
- referencesProvider: true,
111
- documentSymbolProvider: true,
112
- workspaceSymbolProvider: true,
113
- codeLensProvider: {
114
- resolveProvider: false,
113
+ return {
114
+ capabilities: {
115
+ hoverProvider: true,
116
+ completionProvider: {
117
+ resolveProvider: false,
118
+ triggerCharacters: ["...", "@"],
119
+ },
120
+ definitionProvider: true,
121
+ referencesProvider: true,
122
+ documentSymbolProvider: true,
123
+ workspaceSymbolProvider: true,
124
+ codeLensProvider: {
125
+ resolveProvider: false,
126
+ },
127
+ codeActionProvider: true,
128
+ executeCommandProvider: {
129
+ commands: [],
130
+ },
131
+ textDocumentSync: TextDocumentSyncKind.Full,
115
132
  },
116
- codeActionProvider: true,
117
- executeCommandProvider: {
118
- commands: [],
119
- },
120
- textDocumentSync: TextDocumentSyncKind.Full,
121
- } as ServerCapabilities,
122
- };
123
- });
133
+ };
134
+ },
135
+ );
124
136
 
125
137
  connection.onInitialized(async () => {
126
- initializeConnection();
138
+ initializeConnection(connection);
127
139
  if (hasWorkspaceFolderCapability) {
128
140
  connection.workspace.onDidChangeWorkspaceFolders(async (event) => {
129
141
  await Promise.all([
@@ -147,7 +159,10 @@ function isFile(uri: string) {
147
159
  }
148
160
 
149
161
  documents.onDidChangeContent((params) => {
150
- const project = workspace.projectForFile(params.document.uri);
162
+ const project = workspace.projectForFile(
163
+ params.document.uri,
164
+ params.document.languageId,
165
+ );
151
166
  if (!project) return;
152
167
 
153
168
  // Only watch changes to files
@@ -160,12 +175,16 @@ documents.onDidChangeContent((params) => {
160
175
 
161
176
  documents.onDidOpen(
162
177
  (params) =>
163
- workspace.projectForFile(params.document.uri)?.onDidOpen?.(params),
178
+ workspace
179
+ .projectForFile(params.document.uri, params.document.languageId)
180
+ ?.onDidOpen?.(params),
164
181
  );
165
182
 
166
183
  documents.onDidClose(
167
184
  (params) =>
168
- workspace.projectForFile(params.document.uri)?.onDidClose?.(params),
185
+ workspace
186
+ .projectForFile(params.document.uri, params.document.languageId)
187
+ ?.onDidClose?.(params),
169
188
  );
170
189
 
171
190
  connection.onDidChangeWatchedFiles((params) => {
@@ -246,28 +265,31 @@ connection.onWorkspaceSymbol(async (params, token) => {
246
265
 
247
266
  connection.onCompletion(
248
267
  debounceHandler(
249
- (params, token, workDoneProgress, resultProgress) =>
268
+ ((params, token, workDoneProgress, resultProgress) =>
250
269
  workspace
251
270
  .projectForFile(params.textDocument.uri)
252
- ?.onCompletion?.(params, token, workDoneProgress, resultProgress) ?? [],
271
+ ?.onCompletion?.(params, token, workDoneProgress, resultProgress) ??
272
+ []) satisfies Parameters<typeof connection.onCompletion>[0],
253
273
  ),
254
274
  );
255
275
 
256
276
  connection.onCodeLens(
257
277
  debounceHandler(
258
- (params, token, workDoneProgress, resultProgress) =>
278
+ ((params, token, workDoneProgress, resultProgress) =>
259
279
  workspace
260
280
  .projectForFile(params.textDocument.uri)
261
- ?.onCodeLens?.(params, token, workDoneProgress, resultProgress) ?? [],
281
+ ?.onCodeLens?.(params, token, workDoneProgress, resultProgress) ??
282
+ []) satisfies Parameters<typeof connection.onCodeLens>[0],
262
283
  ),
263
284
  );
264
285
 
265
286
  connection.onCodeAction(
266
287
  debounceHandler(
267
- (params, token, workDoneProgress, resultProgress) =>
288
+ ((params, token, workDoneProgress, resultProgress) =>
268
289
  workspace
269
290
  .projectForFile(params.textDocument.uri)
270
- ?.onCodeAction?.(params, token, workDoneProgress, resultProgress) ?? [],
291
+ ?.onCodeAction?.(params, token, workDoneProgress, resultProgress) ??
292
+ []) satisfies Parameters<typeof connection.onCodeAction>[0],
271
293
  ),
272
294
  );
273
295
 
@@ -0,0 +1,39 @@
1
+ import {
2
+ FileExtension,
3
+ LanguageIdExtensionMap,
4
+ supportedLanguageIds,
5
+ } from "../../tools/utilities/languageInformation";
6
+
7
+ let languageIdPerExtension: Record<FileExtension, string> | undefined;
8
+ let supportedExtensions: FileExtension[] | undefined;
9
+
10
+ export function setLanguageIdExtensionMap(map: LanguageIdExtensionMap) {
11
+ languageIdPerExtension = Object.fromEntries(
12
+ Object.entries(map).flatMap(([languageId, extensions]) =>
13
+ extensions.map((extension) => [extension, languageId]),
14
+ ),
15
+ );
16
+ supportedExtensions = supportedLanguageIds.flatMap(
17
+ (languageId) => map[languageId],
18
+ );
19
+ }
20
+
21
+ /**
22
+ * @throws if called before the language server has received options via `onInitialize`.
23
+ */
24
+ export function getLanguageIdForExtension(ext: FileExtension) {
25
+ if (!languageIdPerExtension) {
26
+ throw new Error("LanguageIdExtensionMap not set");
27
+ }
28
+ return languageIdPerExtension[ext];
29
+ }
30
+
31
+ /**
32
+ * @throws if called before the language server has received options via `onInitialize`.
33
+ */
34
+ export function getSupportedExtensions() {
35
+ if (!supportedExtensions) {
36
+ throw new Error("LanguageIdExtensionMap not set");
37
+ }
38
+ return supportedExtensions;
39
+ }
@@ -17,6 +17,7 @@ import { Debug } from "./utilities";
17
17
  import type { EngineDecoration } from "../messages";
18
18
  import { equal } from "@wry/equality";
19
19
  import { isRoverConfig, RoverProject } from "./project/rover/project";
20
+ import { VSCodeConnection } from "./server";
20
21
 
21
22
  export interface WorkspaceConfig {
22
23
  clientIdentity: ClientIdentity;
@@ -35,6 +36,7 @@ export class GraphQLWorkspace {
35
36
  constructor(
36
37
  private LanguageServerLoadingHandler: LanguageServerLoadingHandler,
37
38
  private config: WorkspaceConfig,
39
+ private whenConnectionInitialized: Promise<VSCodeConnection>,
38
40
  ) {}
39
41
 
40
42
  onDiagnostics(handler: NotificationHandler<PublishDiagnosticsParams>) {
@@ -98,6 +100,11 @@ export class GraphQLWorkspace {
98
100
  // base class which is used by codegen and other tools
99
101
  project.whenReady.then(() => project.validate?.());
100
102
 
103
+ if (project.onVSCodeConnectionInitialized) {
104
+ this.whenConnectionInitialized.then(
105
+ project.onVSCodeConnectionInitialized.bind(project),
106
+ );
107
+ }
101
108
  return project;
102
109
  }
103
110
 
@@ -201,7 +208,7 @@ export class GraphQLWorkspace {
201
208
  error = e;
202
209
  }
203
210
 
204
- const project = this.projectForFile(configUri);
211
+ const project = this.projectForConfigFile(configUri);
205
212
 
206
213
  if (this._onConfigFilesFound) {
207
214
  this._onConfigFilesFound([config || error]);
@@ -261,14 +268,31 @@ export class GraphQLWorkspace {
261
268
  return Array.from(this.projectsByFolderUri.values()).flat();
262
269
  }
263
270
 
264
- projectForFile(uri: DocumentUri): GraphQLProject | undefined {
271
+ projectForConfigFile(configUri: DocumentUri): GraphQLProject | undefined {
272
+ for (const projects of this.projectsByFolderUri.values()) {
273
+ const project = projects.find((project) =>
274
+ project.isConfiguredBy(configUri),
275
+ );
276
+ if (project) {
277
+ return project;
278
+ }
279
+ }
280
+ return undefined;
281
+ }
282
+
283
+ projectForFile(
284
+ uri: DocumentUri,
285
+ languageId?: string,
286
+ ): GraphQLProject | undefined {
265
287
  const cachedResult = this._projectForFileCache.get(uri);
266
288
  if (cachedResult) {
267
289
  return cachedResult;
268
290
  }
269
291
 
270
292
  for (const projects of this.projectsByFolderUri.values()) {
271
- const project = projects.find((project) => project.includesFile(uri));
293
+ const project = projects.find((project) =>
294
+ project.includesFile(uri, languageId),
295
+ );
272
296
  if (project) {
273
297
  this._projectForFileCache.set(uri, project);
274
298
  return project;
@@ -6,9 +6,17 @@ import {
6
6
  RevealOutputChannelOn,
7
7
  } from "vscode-languageclient/node";
8
8
  import { workspace, OutputChannel } from "vscode";
9
+ import { supportedLanguageIds } from "./tools/utilities/languageInformation";
10
+ import type { InitializationOptions } from "./language-server/server";
11
+ import { getLangugageInformation } from "./tools/utilities/getLanguageInformation";
9
12
 
10
13
  const { version, referenceID } = require("../package.json");
11
14
 
15
+ const languageIdExtensionMap = getLangugageInformation();
16
+ const supportedExtensions = supportedLanguageIds.flatMap(
17
+ (id) => languageIdExtensionMap[id],
18
+ );
19
+
12
20
  export function getLanguageServerClient(
13
21
  serverModule: string,
14
22
  outputChannel: OutputChannel,
@@ -41,25 +49,12 @@ export function getLanguageServerClient(
41
49
  };
42
50
 
43
51
  const clientOptions: LanguageClientOptions = {
44
- documentSelector: [
45
- "graphql",
46
- "javascript",
47
- "typescript",
48
- "javascriptreact",
49
- "typescriptreact",
50
- "vue",
51
- "svelte",
52
- "python",
53
- "ruby",
54
- "dart",
55
- "reason",
56
- "elixir",
57
- ],
52
+ documentSelector: supportedLanguageIds,
58
53
  synchronize: {
59
54
  fileEvents: [
60
55
  workspace.createFileSystemWatcher("**/.env?(.local)"),
61
56
  workspace.createFileSystemWatcher(
62
- "**/*.{graphql,js,ts,cjs,mjs,jsx,tsx,vue,svelte,py,rb,dart,re,ex,exs}",
57
+ "**/*{" + supportedExtensions.join(",") + "}",
63
58
  ),
64
59
  ],
65
60
  },
@@ -69,6 +64,9 @@ export function getLanguageServerClient(
69
64
  .get("debug.revealOutputOnLanguageServerError")
70
65
  ? RevealOutputChannelOn.Error
71
66
  : RevealOutputChannelOn.Never,
67
+ initializationOptions: {
68
+ languageIdExtensionMap,
69
+ } satisfies InitializationOptions,
72
70
  };
73
71
 
74
72
  return new LanguageClient(
@@ -0,0 +1,41 @@
1
+ import * as vscode from "vscode";
2
+ import {
3
+ LanguageIdExtensionMap,
4
+ minimumKnownExtensions,
5
+ } from "./languageInformation";
6
+
7
+ /**
8
+ * @returns An object with language identifiers as keys and file extensions as values.
9
+ * see https://github.com/microsoft/vscode/issues/109919
10
+ */
11
+ export function getLangugageInformation(): LanguageIdExtensionMap {
12
+ const allKnownExtensions = vscode.extensions.all
13
+ .map(
14
+ (i) =>
15
+ i.packageJSON?.contributes?.languages as (
16
+ | undefined
17
+ | {
18
+ id?: string;
19
+ extensions?: string[];
20
+ }
21
+ )[],
22
+ )
23
+ .flat()
24
+ .filter(
25
+ (i): i is { id: string; extensions: `.${string}`[] } =>
26
+ !!(i && i.id && i.extensions?.length),
27
+ )
28
+ .reduce<Record<string, Set<`.${string}`>>>(
29
+ (acc, i) => {
30
+ if (!acc[i.id]) acc[i.id] = new Set();
31
+ for (const ext of i.extensions) acc[i.id].add(ext);
32
+ return acc;
33
+ },
34
+ Object.fromEntries(
35
+ Object.entries(minimumKnownExtensions).map(([k, v]) => [k, new Set(v)]),
36
+ ),
37
+ );
38
+ return Object.fromEntries(
39
+ Object.entries(allKnownExtensions).map(([k, v]) => [k, [...v]] as const),
40
+ ) as LanguageIdExtensionMap;
41
+ }
@@ -0,0 +1,41 @@
1
+ const _supportedDocumentTypes = [
2
+ "graphql",
3
+ "javascript",
4
+ "typescript",
5
+ "javascriptreact",
6
+ "typescriptreact",
7
+ "vue",
8
+ "svelte",
9
+ "python",
10
+ "ruby",
11
+ "dart",
12
+ "reason",
13
+ "elixir",
14
+ ] as const;
15
+ export type SupportedLanguageIds = (typeof _supportedDocumentTypes)[number];
16
+ export const supportedLanguageIds =
17
+ // remove the `readonly` we get from using `as const`
18
+ _supportedDocumentTypes as any as SupportedLanguageIds[];
19
+
20
+ export type FileExtension = `.${string}`;
21
+
22
+ export const minimumKnownExtensions: Record<
23
+ SupportedLanguageIds,
24
+ FileExtension[]
25
+ > = {
26
+ graphql: [".gql", ".graphql", ".graphqls"],
27
+ javascript: [".js", ".mjs", ".cjs"],
28
+ typescript: [".ts", ".mts", ".cts"],
29
+ javascriptreact: [".jsx"],
30
+ typescriptreact: [".tsx"],
31
+ vue: [".vue"],
32
+ svelte: [".svelte"],
33
+ python: [".py"],
34
+ ruby: [".rb"],
35
+ dart: [".dart"],
36
+ reason: [".re"],
37
+ elixir: [".ex", ".exs"],
38
+ };
39
+
40
+ export type LanguageIdExtensionMap = Record<string, `.${string}`[]> &
41
+ typeof minimumKnownExtensions;