xref: /llvm-project/mlir/utils/vscode/src/mlirContext.ts (revision 86db2154bc27bf64212b91c76ed67b7dd1fc5eb0)
1import * as fs from 'fs';
2import * as path from 'path';
3import * as vscode from 'vscode';
4import * as vscodelc from 'vscode-languageclient/node';
5
6import * as config from './config';
7import * as configWatcher from './configWatcher';
8
9/**
10 *  This class represents the context of a specific workspace folder.
11 */
12class WorkspaceFolderContext implements vscode.Disposable {
13  dispose() {
14    this.clients.forEach(async client => await client.stop());
15    this.clients.clear();
16  }
17
18  clients: Map<string, vscodelc.LanguageClient> = new Map();
19}
20
21/**
22 *  This class manages all of the MLIR extension state,
23 *  including the language client.
24 */
25export class MLIRContext implements vscode.Disposable {
26  subscriptions: vscode.Disposable[] = [];
27  workspaceFolders: Map<string, WorkspaceFolderContext> = new Map();
28  outputChannel: vscode.OutputChannel;
29
30  /**
31   *  Activate the MLIR context, and start the language clients.
32   */
33  async activate(outputChannel: vscode.OutputChannel) {
34    this.outputChannel = outputChannel;
35
36    // This lambda is used to lazily start language clients for the given
37    // document. It removes the need to pro-actively start language clients for
38    // every folder within the workspace and every language type we provide.
39    const startClientOnOpenDocument = async (document: vscode.TextDocument) => {
40      await this.getOrActivateLanguageClient(document.uri, document.languageId);
41    };
42    // Process any existing documents.
43    for (const textDoc of vscode.workspace.textDocuments) {
44      await startClientOnOpenDocument(textDoc);
45    }
46
47    // Watch any new documents to spawn servers when necessary.
48    this.subscriptions.push(
49        vscode.workspace.onDidOpenTextDocument(startClientOnOpenDocument));
50    this.subscriptions.push(
51        vscode.workspace.onDidChangeWorkspaceFolders((event) => {
52          for (const folder of event.removed) {
53            const client = this.workspaceFolders.get(folder.uri.toString());
54            if (client) {
55              client.dispose();
56              this.workspaceFolders.delete(folder.uri.toString());
57            }
58          }
59        }));
60  }
61
62  /**
63   * Open or return a language server for the given uri and language.
64   */
65  async getOrActivateLanguageClient(uri: vscode.Uri, languageId: string):
66      Promise<vscodelc.LanguageClient> {
67    let serverSettingName: string;
68    if (languageId === 'mlir') {
69      serverSettingName = 'server_path';
70    } else if (languageId === 'pdll') {
71      serverSettingName = 'pdll_server_path';
72    } else if (languageId === 'tablegen') {
73      serverSettingName = 'tablegen_server_path';
74    } else {
75      return null;
76    }
77
78    // Check the scheme of the uri.
79    let validSchemes = [ 'file', 'mlir.bytecode-mlir' ];
80    if (!validSchemes.includes(uri.scheme)) {
81      return null;
82    }
83
84    // Resolve the workspace folder if this document is in one. We use the
85    // workspace folder when determining if a server needs to be started.
86    let workspaceFolder = vscode.workspace.getWorkspaceFolder(uri);
87    let workspaceFolderStr =
88        workspaceFolder ? workspaceFolder.uri.toString() : "";
89
90    // Get or create a client context for this folder.
91    let folderContext = this.workspaceFolders.get(workspaceFolderStr);
92    if (!folderContext) {
93      folderContext = new WorkspaceFolderContext();
94      this.workspaceFolders.set(workspaceFolderStr, folderContext);
95    }
96    // Start the client for this language if necessary.
97    let client = folderContext.clients.get(languageId);
98    if (!client) {
99      client = await this.activateWorkspaceFolder(
100          workspaceFolder, serverSettingName, languageId, this.outputChannel);
101      folderContext.clients.set(languageId, client);
102    }
103    return client;
104  }
105
106  /**
107   *  Prepare a compilation database option for a server.
108   */
109  async prepareCompilationDatabaseServerOptions(
110      languageName: string, workspaceFolder: vscode.WorkspaceFolder,
111      configsToWatch: string[], pathsToWatch: string[],
112      additionalServerArgs: string[]) {
113    // Process the compilation databases attached for the workspace folder.
114    let databases = config.get<string[]>(
115        `${languageName}_compilation_databases`, workspaceFolder, []);
116
117    // If no databases were explicitly specified, default to a database in the
118    // 'build' directory within the current workspace.
119    if (databases.length === 0) {
120      if (workspaceFolder) {
121        databases.push(workspaceFolder.uri.fsPath +
122                       `/build/${languageName}_compile_commands.yml`);
123      }
124
125      // Otherwise, try to resolve each of the paths.
126    } else {
127      for await (let database of databases) {
128        database = await this.resolvePath(database, '', workspaceFolder);
129      }
130    }
131
132    configsToWatch.push(`${languageName}_compilation_databases`);
133    pathsToWatch.push(...databases);
134
135    // Setup the compilation databases as additional arguments to pass to the
136    // server.
137    databases.filter(database => database !== '');
138    additionalServerArgs.push(...databases.map(
139        (database) => `--${languageName}-compilation-database=${database}`));
140  }
141
142  /**
143   *  Prepare the server options for a PDLL server, e.g. populating any
144   *  accessible compilation databases.
145   */
146  async preparePDLLServerOptions(workspaceFolder: vscode.WorkspaceFolder,
147                                 configsToWatch: string[],
148                                 pathsToWatch: string[],
149                                 additionalServerArgs: string[]) {
150    await this.prepareCompilationDatabaseServerOptions(
151        'pdll', workspaceFolder, configsToWatch, pathsToWatch,
152        additionalServerArgs);
153  }
154
155  /**
156   *  Prepare the server options for a TableGen server, e.g. populating any
157   *  accessible compilation databases.
158   */
159  async prepareTableGenServerOptions(workspaceFolder: vscode.WorkspaceFolder,
160                                     configsToWatch: string[],
161                                     pathsToWatch: string[],
162                                     additionalServerArgs: string[]) {
163    await this.prepareCompilationDatabaseServerOptions(
164        'tablegen', workspaceFolder, configsToWatch, pathsToWatch,
165        additionalServerArgs);
166  }
167
168  /**
169   *  Activate the language client for the given language in the given workspace
170   *  folder.
171   */
172  async activateWorkspaceFolder(workspaceFolder: vscode.WorkspaceFolder,
173                                serverSettingName: string, languageName: string,
174                                outputChannel: vscode.OutputChannel):
175      Promise<vscodelc.LanguageClient> {
176    let configsToWatch: string[] = [];
177    let filepathsToWatch: string[] = [];
178    let additionalServerArgs: string[] = [];
179    additionalServerArgs = config.get<string[]>(languageName + "_additional_server_args", null, []);
180
181    // Initialize additional configurations for this server.
182    if (languageName === 'pdll') {
183      await this.preparePDLLServerOptions(workspaceFolder, configsToWatch,
184                                          filepathsToWatch,
185                                          additionalServerArgs);
186    } else if (languageName == 'tablegen') {
187      await this.prepareTableGenServerOptions(workspaceFolder, configsToWatch,
188                                              filepathsToWatch,
189                                              additionalServerArgs);
190    }
191
192    // Try to activate the language client.
193    const [server, serverPath] = await this.startLanguageClient(
194        workspaceFolder, outputChannel, serverSettingName, languageName,
195        additionalServerArgs);
196    configsToWatch.push(serverSettingName);
197    filepathsToWatch.push(serverPath);
198
199    // Watch for configuration changes on this folder.
200    await configWatcher.activate(this, workspaceFolder, configsToWatch,
201                                 filepathsToWatch);
202    return server;
203  }
204
205  /**
206   *  Start a new language client for the given language. Returns an array
207   *  containing the opened server, or null if the server could not be started,
208   *  and the resolved server path.
209   */
210  async startLanguageClient(workspaceFolder: vscode.WorkspaceFolder,
211                            outputChannel: vscode.OutputChannel,
212                            serverSettingName: string, languageName: string,
213                            additionalServerArgs: string[]):
214      Promise<[ vscodelc.LanguageClient, string ]> {
215    const clientTitle = languageName.toUpperCase() + ' Language Client';
216
217    // Get the path of the lsp-server that is used to provide language
218    // functionality.
219    var serverPath =
220        await this.resolveServerPath(serverSettingName, workspaceFolder);
221
222    // If the server path is empty, bail. We don't emit errors if the user
223    // hasn't explicitly configured the server.
224    if (serverPath === '') {
225      return [ null, serverPath ];
226    }
227
228    // Check that the file actually exists.
229    if (!fs.existsSync(serverPath)) {
230      vscode.window
231          .showErrorMessage(
232              `${clientTitle}: Unable to resolve path for '${
233                  serverSettingName}', please ensure the path is correct`,
234              "Open Setting")
235          .then((value) => {
236            if (value === "Open Setting") {
237              vscode.commands.executeCommand(
238                  'workbench.action.openWorkspaceSettings',
239                  {openToSide : false, query : `mlir.${serverSettingName}`});
240            }
241          });
242      return [ null, serverPath ];
243    }
244
245    // Configure the server options.
246    const serverOptions: vscodelc.ServerOptions = {
247      command : serverPath,
248      args : additionalServerArgs
249    };
250
251    // Configure file patterns relative to the workspace folder.
252    let filePattern: vscode.GlobPattern = '**/*.' + languageName;
253    let selectorPattern: string = null;
254    if (workspaceFolder) {
255      filePattern = new vscode.RelativePattern(workspaceFolder, filePattern);
256      selectorPattern = `${workspaceFolder.uri.fsPath}/**/*`;
257    }
258
259    // Configure the middleware of the client. This is sort of abused to allow
260    // for defining a "fallback" language server that operates on non-workspace
261    // folders. Workspace folder language servers can properly filter out
262    // documents not within the folder, but we can't effectively filter for
263    // documents outside of the workspace. To support this, and avoid having two
264    // servers targeting the same set of files, we use middleware to inject the
265    // dynamic logic for checking if a document is in the workspace.
266    let middleware = {};
267    if (!workspaceFolder) {
268      middleware = {
269        didOpen : (document, next) : Promise<void> => {
270          if (!vscode.workspace.getWorkspaceFolder(document.uri)) {
271            return next(document);
272          }
273          return Promise.resolve();
274        }
275      };
276    }
277
278    // Configure the client options.
279    const clientOptions: vscodelc.LanguageClientOptions = {
280      documentSelector : [
281        {language : languageName, pattern : selectorPattern},
282      ],
283      synchronize : {
284        // Notify the server about file changes to language files contained in
285        // the workspace.
286        fileEvents : vscode.workspace.createFileSystemWatcher(filePattern)
287      },
288      outputChannel : outputChannel,
289      workspaceFolder : workspaceFolder,
290      middleware : middleware,
291
292      // Don't switch to output window when the server returns output.
293      revealOutputChannelOn : vscodelc.RevealOutputChannelOn.Never,
294    };
295
296    // Create the language client and start the client.
297    let languageClient = new vscodelc.LanguageClient(
298        languageName + '-lsp', clientTitle, serverOptions, clientOptions);
299    languageClient.start();
300    return [ languageClient, serverPath ];
301  }
302
303  /**
304   * Given a server setting, return the default server path.
305   */
306  static getDefaultServerFilename(serverSettingName: string): string {
307    if (serverSettingName === 'pdll_server_path') {
308      return 'mlir-pdll-lsp-server';
309    }
310    if (serverSettingName === 'server_path') {
311      return 'mlir-lsp-server';
312    }
313    if (serverSettingName === 'tablegen_server_path') {
314      return 'tblgen-lsp-server';
315    }
316    return '';
317  }
318
319  /**
320   * Try to resolve the given path, or the default path, with an optional
321   * workspace folder. If a path could not be resolved, just returns the
322   * input filePath.
323   */
324  async resolvePath(filePath: string, defaultPath: string,
325                    workspaceFolder: vscode.WorkspaceFolder): Promise<string> {
326    const configPath = filePath;
327
328    // If the path is already fully resolved, there is nothing to do.
329    if (path.isAbsolute(filePath)) {
330      return filePath;
331    }
332
333    // If a path hasn't been set, try to use the default path.
334    if (filePath === '') {
335      if (defaultPath === '') {
336        return filePath;
337      }
338      filePath = defaultPath;
339
340      // Fallthrough to try resolving the default path.
341    }
342
343    // Try to resolve the path relative to the workspace.
344    let filePattern: vscode.GlobPattern = '**/' + filePath;
345    if (workspaceFolder) {
346      filePattern = new vscode.RelativePattern(workspaceFolder, filePattern);
347    }
348    let foundUris = await vscode.workspace.findFiles(filePattern, null, 1);
349    if (foundUris.length === 0) {
350      // If we couldn't resolve it, just return the original path anyways. The
351      // file might not exist yet.
352      return configPath;
353    }
354    // Otherwise, return the resolved path.
355    return foundUris[0].fsPath;
356  }
357
358  /**
359   * Try to resolve the path for the given server setting, with an optional
360   * workspace folder.
361   */
362  async resolveServerPath(serverSettingName: string,
363                          workspaceFolder: vscode.WorkspaceFolder):
364      Promise<string> {
365    const serverPath = config.get<string>(serverSettingName, workspaceFolder);
366    const defaultPath = MLIRContext.getDefaultServerFilename(serverSettingName);
367    return this.resolvePath(serverPath, defaultPath, workspaceFolder);
368  }
369
370  /**
371   * Return the language client for the given language and uri, or null if no
372   * client is active.
373   */
374  getLanguageClient(uri: vscode.Uri,
375                    languageName: string): vscodelc.LanguageClient {
376    let workspaceFolder = vscode.workspace.getWorkspaceFolder(uri);
377    let workspaceFolderStr =
378        workspaceFolder ? workspaceFolder.uri.toString() : "";
379    let folderContext = this.workspaceFolders.get(workspaceFolderStr);
380    if (!folderContext) {
381      return null;
382    }
383    return folderContext.clients.get(languageName);
384  }
385
386  dispose() {
387    this.subscriptions.forEach((d) => { d.dispose(); });
388    this.subscriptions = [];
389    this.workspaceFolders.forEach((d) => { d.dispose(); });
390    this.workspaceFolders.clear();
391  }
392}
393