llvm/mlir/utils/vscode/src/mlirContext.ts

import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import * as vscodelc from 'vscode-languageclient/node';

import * as config from './config';
import * as configWatcher from './configWatcher';

/**
 *  This class represents the context of a specific workspace folder.
 */
class WorkspaceFolderContext implements vscode.Disposable {
  dispose() {
    this.clients.forEach(async client => await client.stop());
    this.clients.clear();
  }

  clients: Map<string, vscodelc.LanguageClient> = new Map();
}

/**
 *  This class manages all of the MLIR extension state,
 *  including the language client.
 */
export class MLIRContext implements vscode.Disposable {
  subscriptions: vscode.Disposable[] = [];
  workspaceFolders: Map<string, WorkspaceFolderContext> = new Map();
  outputChannel: vscode.OutputChannel;

  /**
   *  Activate the MLIR context, and start the language clients.
   */
  async activate(outputChannel: vscode.OutputChannel) {
    this.outputChannel = outputChannel;

    // This lambda is used to lazily start language clients for the given
    // document. It removes the need to pro-actively start language clients for
    // every folder within the workspace and every language type we provide.
    const startClientOnOpenDocument = async (document: vscode.TextDocument) => {
      await this.getOrActivateLanguageClient(document.uri, document.languageId);
    };
    // Process any existing documents.
    for (const textDoc of vscode.workspace.textDocuments) {
      await startClientOnOpenDocument(textDoc);
    }

    // Watch any new documents to spawn servers when necessary.
    this.subscriptions.push(
        vscode.workspace.onDidOpenTextDocument(startClientOnOpenDocument));
    this.subscriptions.push(
        vscode.workspace.onDidChangeWorkspaceFolders((event) => {
          for (const folder of event.removed) {
            const client = this.workspaceFolders.get(folder.uri.toString());
            if (client) {
              client.dispose();
              this.workspaceFolders.delete(folder.uri.toString());
            }
          }
        }));
  }

  /**
   * Open or return a language server for the given uri and language.
   */
  async getOrActivateLanguageClient(uri: vscode.Uri, languageId: string):
      Promise<vscodelc.LanguageClient> {
    let serverSettingName: string;
    if (languageId === 'mlir') {
      serverSettingName = 'server_path';
    } else if (languageId === 'pdll') {
      serverSettingName = 'pdll_server_path';
    } else if (languageId === 'tablegen') {
      serverSettingName = 'tablegen_server_path';
    } else {
      return null;
    }

    // Check the scheme of the uri.
    let validSchemes = [ 'file', 'mlir.bytecode-mlir' ];
    if (!validSchemes.includes(uri.scheme)) {
      return null;
    }

    // Resolve the workspace folder if this document is in one. We use the
    // workspace folder when determining if a server needs to be started.
    let workspaceFolder = vscode.workspace.getWorkspaceFolder(uri);
    let workspaceFolderStr =
        workspaceFolder ? workspaceFolder.uri.toString() : "";

    // Get or create a client context for this folder.
    let folderContext = this.workspaceFolders.get(workspaceFolderStr);
    if (!folderContext) {
      folderContext = new WorkspaceFolderContext();
      this.workspaceFolders.set(workspaceFolderStr, folderContext);
    }
    // Start the client for this language if necessary.
    let client = folderContext.clients.get(languageId);
    if (!client) {
      client = await this.activateWorkspaceFolder(
          workspaceFolder, serverSettingName, languageId, this.outputChannel);
      folderContext.clients.set(languageId, client);
    }
    return client;
  }

  /**
   *  Prepare a compilation database option for a server.
   */
  async prepareCompilationDatabaseServerOptions(
      languageName: string, workspaceFolder: vscode.WorkspaceFolder,
      configsToWatch: string[], pathsToWatch: string[],
      additionalServerArgs: string[]) {
    // Process the compilation databases attached for the workspace folder.
    let databases = config.get<string[]>(
        `${languageName}_compilation_databases`, workspaceFolder, []);

    // If no databases were explicitly specified, default to a database in the
    // 'build' directory within the current workspace.
    if (databases.length === 0) {
      if (workspaceFolder) {
        databases.push(workspaceFolder.uri.fsPath +
                       `/build/${languageName}_compile_commands.yml`);
      }

      // Otherwise, try to resolve each of the paths.
    } else {
      for await (let database of databases) {
        database = await this.resolvePath(database, '', workspaceFolder);
      }
    }

    configsToWatch.push(`${languageName}_compilation_databases`);
    pathsToWatch.push(...databases);

    // Setup the compilation databases as additional arguments to pass to the
    // server.
    databases.filter(database => database !== '');
    additionalServerArgs.push(...databases.map(
        (database) => `--${languageName}-compilation-database=${database}`));
  }

  /**
   *  Prepare the server options for a PDLL server, e.g. populating any
   *  accessible compilation databases.
   */
  async preparePDLLServerOptions(workspaceFolder: vscode.WorkspaceFolder,
                                 configsToWatch: string[],
                                 pathsToWatch: string[],
                                 additionalServerArgs: string[]) {
    await this.prepareCompilationDatabaseServerOptions(
        'pdll', workspaceFolder, configsToWatch, pathsToWatch,
        additionalServerArgs);
  }

  /**
   *  Prepare the server options for a TableGen server, e.g. populating any
   *  accessible compilation databases.
   */
  async prepareTableGenServerOptions(workspaceFolder: vscode.WorkspaceFolder,
                                     configsToWatch: string[],
                                     pathsToWatch: string[],
                                     additionalServerArgs: string[]) {
    await this.prepareCompilationDatabaseServerOptions(
        'tablegen', workspaceFolder, configsToWatch, pathsToWatch,
        additionalServerArgs);
  }

  /**
   *  Activate the language client for the given language in the given workspace
   *  folder.
   */
  async activateWorkspaceFolder(workspaceFolder: vscode.WorkspaceFolder,
                                serverSettingName: string, languageName: string,
                                outputChannel: vscode.OutputChannel):
      Promise<vscodelc.LanguageClient> {
    let configsToWatch: string[] = [];
    let filepathsToWatch: string[] = [];
    let additionalServerArgs: string[] = [];
    additionalServerArgs = config.get<string[]>(languageName + "_additional_server_args", null, []);

    // Initialize additional configurations for this server.
    if (languageName === 'pdll') {
      await this.preparePDLLServerOptions(workspaceFolder, configsToWatch,
                                          filepathsToWatch,
                                          additionalServerArgs);
    } else if (languageName == 'tablegen') {
      await this.prepareTableGenServerOptions(workspaceFolder, configsToWatch,
                                              filepathsToWatch,
                                              additionalServerArgs);
    }

    // Try to activate the language client.
    const [server, serverPath] = await this.startLanguageClient(
        workspaceFolder, outputChannel, serverSettingName, languageName,
        additionalServerArgs);
    configsToWatch.push(serverSettingName);
    filepathsToWatch.push(serverPath);

    // Watch for configuration changes on this folder.
    await configWatcher.activate(this, workspaceFolder, configsToWatch,
                                 filepathsToWatch);
    return server;
  }

  /**
   *  Start a new language client for the given language. Returns an array
   *  containing the opened server, or null if the server could not be started,
   *  and the resolved server path.
   */
  async startLanguageClient(workspaceFolder: vscode.WorkspaceFolder,
                            outputChannel: vscode.OutputChannel,
                            serverSettingName: string, languageName: string,
                            additionalServerArgs: string[]):
      Promise<[ vscodelc.LanguageClient, string ]> {
    const clientTitle = languageName.toUpperCase() + ' Language Client';

    // Get the path of the lsp-server that is used to provide language
    // functionality.
    var serverPath =
        await this.resolveServerPath(serverSettingName, workspaceFolder);

    // If the server path is empty, bail. We don't emit errors if the user
    // hasn't explicitly configured the server.
    if (serverPath === '') {
      return [ null, serverPath ];
    }

    // Check that the file actually exists.
    if (!fs.existsSync(serverPath)) {
      vscode.window
          .showErrorMessage(
              `${clientTitle}: Unable to resolve path for '${
                  serverSettingName}', please ensure the path is correct`,
              "Open Setting")
          .then((value) => {
            if (value === "Open Setting") {
              vscode.commands.executeCommand(
                  'workbench.action.openWorkspaceSettings',
                  {openToSide : false, query : `mlir.${serverSettingName}`});
            }
          });
      return [ null, serverPath ];
    }

    // Configure the server options.
    const serverOptions: vscodelc.ServerOptions = {
      command : serverPath,
      args : additionalServerArgs
    };

    // Configure file patterns relative to the workspace folder.
    let filePattern: vscode.GlobPattern = '**/*.' + languageName;
    let selectorPattern: string = null;
    if (workspaceFolder) {
      filePattern = new vscode.RelativePattern(workspaceFolder, filePattern);
      selectorPattern = `${workspaceFolder.uri.fsPath}/**/*`;
    }

    // Configure the middleware of the client. This is sort of abused to allow
    // for defining a "fallback" language server that operates on non-workspace
    // folders. Workspace folder language servers can properly filter out
    // documents not within the folder, but we can't effectively filter for
    // documents outside of the workspace. To support this, and avoid having two
    // servers targeting the same set of files, we use middleware to inject the
    // dynamic logic for checking if a document is in the workspace.
    let middleware = {};
    if (!workspaceFolder) {
      middleware = {
        didOpen : (document, next) : Promise<void> => {
          if (!vscode.workspace.getWorkspaceFolder(document.uri)) {
            return next(document);
          }
          return Promise.resolve();
        }
      };
    }

    // Configure the client options.
    const clientOptions: vscodelc.LanguageClientOptions = {
      documentSelector : [
        {language : languageName, pattern : selectorPattern},
      ],
      synchronize : {
        // Notify the server about file changes to language files contained in
        // the workspace.
        fileEvents : vscode.workspace.createFileSystemWatcher(filePattern)
      },
      outputChannel : outputChannel,
      workspaceFolder : workspaceFolder,
      middleware : middleware,

      // Don't switch to output window when the server returns output.
      revealOutputChannelOn : vscodelc.RevealOutputChannelOn.Never,
    };

    // Create the language client and start the client.
    let languageClient = new vscodelc.LanguageClient(
        languageName + '-lsp', clientTitle, serverOptions, clientOptions);
    languageClient.start();
    return [ languageClient, serverPath ];
  }

  /**
   * Given a server setting, return the default server path.
   */
  static getDefaultServerFilename(serverSettingName: string): string {
    if (serverSettingName === 'pdll_server_path') {
      return 'mlir-pdll-lsp-server';
    }
    if (serverSettingName === 'server_path') {
      return 'mlir-lsp-server';
    }
    if (serverSettingName === 'tablegen_server_path') {
      return 'tblgen-lsp-server';
    }
    return '';
  }

  /**
   * Try to resolve the given path, or the default path, with an optional
   * workspace folder. If a path could not be resolved, just returns the
   * input filePath.
   */
  async resolvePath(filePath: string, defaultPath: string,
                    workspaceFolder: vscode.WorkspaceFolder): Promise<string> {
    const configPath = filePath;

    // If the path is already fully resolved, there is nothing to do.
    if (path.isAbsolute(filePath)) {
      return filePath;
    }

    // If a path hasn't been set, try to use the default path.
    if (filePath === '') {
      if (defaultPath === '') {
        return filePath;
      }
      filePath = defaultPath;

      // Fallthrough to try resolving the default path.
    }

    // Try to resolve the path relative to the workspace.
    let filePattern: vscode.GlobPattern = '**/' + filePath;
    if (workspaceFolder) {
      filePattern = new vscode.RelativePattern(workspaceFolder, filePattern);
    }
    let foundUris = await vscode.workspace.findFiles(filePattern, null, 1);
    if (foundUris.length === 0) {
      // If we couldn't resolve it, just return the original path anyways. The
      // file might not exist yet.
      return configPath;
    }
    // Otherwise, return the resolved path.
    return foundUris[0].fsPath;
  }

  /**
   * Try to resolve the path for the given server setting, with an optional
   * workspace folder.
   */
  async resolveServerPath(serverSettingName: string,
                          workspaceFolder: vscode.WorkspaceFolder):
      Promise<string> {
    const serverPath = config.get<string>(serverSettingName, workspaceFolder);
    const defaultPath = MLIRContext.getDefaultServerFilename(serverSettingName);
    return this.resolvePath(serverPath, defaultPath, workspaceFolder);
  }

  /**
   * Return the language client for the given language and uri, or null if no
   * client is active.
   */
  getLanguageClient(uri: vscode.Uri,
                    languageName: string): vscodelc.LanguageClient {
    let workspaceFolder = vscode.workspace.getWorkspaceFolder(uri);
    let workspaceFolderStr =
        workspaceFolder ? workspaceFolder.uri.toString() : "";
    let folderContext = this.workspaceFolders.get(workspaceFolderStr);
    if (!folderContext) {
      return null;
    }
    return folderContext.clients.get(languageName);
  }

  dispose() {
    this.subscriptions.forEach((d) => { d.dispose(); });
    this.subscriptions = [];
    this.workspaceFolders.forEach((d) => { d.dispose(); });
    this.workspaceFolders.clear();
  }
}