Эта статья продолжает небольшую серию «Создаём ваш первый плагин для…», в которую уже вошли статьи про написания плагина для Grunt, Gulp и VS Code.

Если вы не читали первую статью, в которой рассматривается теория и создание стандартного плагина, то самое время прочитать её.

Пишем плагин языкового сервера

Как я говорил в первой статье, плагины, использующие языковой сервер, применяются в том случае, если есть необходимость обработки «больших» данных. Например, если есть потребность валидации данных, то код, отвечающий за валидацию, идёт прямой дорогой в языковой сервер. Туда же дорога тому коду, что будет выполняться в фоне.

Есть два пути организации файловой структуры такого плагина. Первая, рекомендуемая разработчиками VS Code, подразумевает наличие двух директорий: plugin и plugin-server, каждая из которых имеет свой файл манифеста. Например, такую структуру можно увидеть в плагине vscode-eslint. Основная идея разделения плагина на две директории — возможность раздельного дебаггинга. Вторая, общая структура, подразумевает отсутствие лишних директорий. В этом случае в корне директории плагина находится файл extension.js и server.js.

На самом деле разницы никакой нет, потому что на клиентской части вы всё равно будете указывать место, где VS Code будет искать модуль серверной стороны.

Постановка задачи

В этой части статьи я буду описывать процесс написания плагина для VS Code на основе pug-lint. Как не сложно догадаться, это линтер для Pug (ранее Jade).

Манифест

Активация плагина будет происходить только в момент открытия Jade- или Pug-файлов. Нужно сказать, что сейчас для VS Code нет разницы перед Jade и Pug, однако, стоит перестраховаться.

"activationEvents": [
  "onLanguage:pug",
  "onLanguage:jade"
]

Одной из многих отличительных черт между обычным расширением и языковым сервером, является обязательное наличие опции .enable в настройках редактора. Эта опция, как не сложно догадаться, позволяет включать и отключать плагин. Помимо этого, в наших же интересах будет объявлена опция .config, отвечающая за настройки валидатора.

"contributes": {
  "configuration": {
    "type": "object",
    "title": "puglint configuration options",
    "properties": {
      "puglint.enable": {
        "type": "boolean",
        "default": true,
        "description": "Control whether pug-lint is enabled for Pug/Jade files or not."
      },
      "puglint.config": {
        "type": "object",
        "default": null,
        "description": "A pug-lint configuration object."
      }
    }
  }
}

Клиентская сторона

Инициализация плагина, написанного с языковым сервером, происходит точно так же, как и у обычного плагина. Поэтому, в первую очередь, необходимо создать файл extension.js и уже в нём подсказать VS Code, где искать серверную часть плагина.

К сожалению, особо рассказывать здесь нечего и всё описание происходящего будет представлено в комментариях к коду:

'use strict';

const path = require('path');
const userHome = require('os').homedir();

const vscode = require('vscode');
const { LanguageClient, SettingMonitor } = require('vscode-languageclient');

function activate(context) {
  // Определяем путь до модуля, содержащего серверную сторону плагина.
  const serverModule = path.join(__dirname, 'server.js');

  // Определяем клиентскую часть.
  const client = new LanguageClient('puglint', {
    // При запуске будет создаваться языковой сервер, путь до модуля которого
    // был определён ранее. Свойство `run` представляет собой обычный режим, а
    // свойство `debug` — режим дебаггинга.
    run: {
      module: serverModule
    },
    debug: {
      module: serverModule,
      options: {
        execArgv: ['--nolazy']
      }
    }
  }, {
    // Регистрация сервера только для файлов с языком `pug` или `jade`.
    documentSelector: ['jade', 'pug'],

    // Описание синхронизации настроек.
    synchronize: {
      // На серверную сторону будет передаваться секция настроек puglint каждый раз,
      // когда она будет изменена в настройках редактора.
      configurationSection: 'puglint',

      // Объявляем вотчеры для наблюдения за конфигурационными файлами. Можно
      // указать один вотчер или массив, как ниже.
      fileEvents: [
        vscode.workspace.createFileSystemWatcher(`{${userHome},**}/.{jade-lint,pug-lint}{rc,.json,rc.json}`),
        vscode.workspace.createFileSystemWatcher(`**/package.json`)
      ]
    }
  });

  // Создание языкового сервера и запуск клиента.
  context.subscriptions.push(new SettingMonitor(client, 'puglint.enable').start());
}

exports.activate = activate;

Описанный код выше можно копипастить из плагина в плагин, изменяя лишь расширения файлов, для которых регистрируется сервер и описание синхронизации настроек. Никаких чудес здесь нет.

Серверная сторона

А вот на серверной стороне немного магии всё-таки найдётся. Всё общение между сервером и клиентом осуществляется через специальный API, поэтому просто так даже console.log вызвать не получится.

Создание соединения между клиентом и сервером

Наипростейшая операция, которую можно выполнить с закрытыми глазами.

// Просто импортирование необходимых в плагине методов из модуля
const {
  createConnection,
  TextDocuments,
  DiagnosticSeverity,
  Files,
  ErrorMessageTracker,
  ResponseError
} = require('vscode-languageserver');

// Создание соединения между клиентской и серверной частью.
const connection = createConnection(process.stdin, process.stdout);

// Получение всех открытых документов необходимого расширения,
// указанного в манифесте.
const documents = new TextDocuments();

Всё, теперь у сервера есть мостик, по которому будет происходить общение с клиентом. Теперь вы сможете использовать, например, console.log, однако, для этого потребуется указывать соединение. Например, чтобы вывести в консоль все открыте документы, необходимо будет вызвать команду: connection.console.log(documents). К слову, проблема отсутствия поддержки console.log кроется в том, что VS Code отображает данные на клиентской стороне.

Немного про получение конфига

Получение конфигурационных файлов — это головная боль разработчика плагина, а не VS Code. Для этой операции редактор не предоставляет какого-либо специализированного API, поэтому у разработчика на выбор три пути:

  • В случае, если у линтера есть доступный для этого API — использовать его.
  • Если же у линтера API нет, то попытаться достучаться до него.
  • Если достучаться до соответствующих функций не получилось, то писать всё самому.

В нашем случае открытого API подключения конфигов нет, поэтому я буду добираться до него окольными путями. А вообще, в стандартном исполнении иерархия конфигурационных файлов строится следующим образом:

  • Настройки редактора
  • Конфиг в открытой директории (текущий проект)
  • Настройки в файле package.json
  • Глобальный конфиг ($HOME директория)
  • Настройки плагина по умолчанию

То есть, в самом худшем случае нужно проверить минимум четыре места и, если каждый оказывается пустым, применить конфиг по умолчанию.

События

Обратимся к событиям, которые обязательно следует обрабатывать разработчику плагина. Полный листинг кода представлен в файле server.js на строках L111-L161.

Самым важным событием является событие инициализации плагина. Оно выстреливает тогда, когда пользователь открывает файл с указанным расширением.

Здесь нам придётся с вами поговорить на тему оптимизации вашего времени на поддержку плагина. Дело в том, что скорее всего вы не являетесь ментейнером модулей, что используете в своём плагине. Это означает, что модуль может быть обновлён в любой момент, а пользователи вашего плагина, как правило, не дураки и хотят получать всё самое свежее. При публикации плагина в маркете, вместе с кодом приложения, в распространяемый пакет включается и директория node_modules, из которой удаляется всё лишнее и остаётся лишь то, что указано в секции dependencies в файле package.json. Поэтому каждый раз, когда выходит интересное или значимое обновление модуля, вы будете вынуждены публиковать новую версию плагина. Это уныло, особенно учитывая, что линтер может обновляться раз в неделю. Поэтому для решения этой проблемы разработчики VS Code предложили выносить такие модули из плагина в свободное плавание, а именно устанавливать их локально или глобально. После этого искать модуль с помощью специального API или написать свой «поисковик» модуля.

В нашем случае придётся писать свой велосипед из-за того, что pug-lint не предоставляет удобного доступа к API поиска конфигурационных файлов. Единственным способом получить доступ к модулю, отвечающему за поиск конфигурационных файлов — напрямую подключить его. Это не позволяет сделать API VS Code, так как соответствующий метод просто возвращает уже подключённый главный файл модуля.

Итак, имплементируем возможность поиска модуля. Для этого воспользуемся пакетами which и resolve. Модуль which здесь используется исходя из соображений упрощения поиска директории, куда устанавливаются глобальные модули.

'use strict';

const path = require('path');
const which = require('which');
const resModule = require('resolve');

function resolveModule(name, workspace) {
  return new Promise((resolve, reject) => {
    // Поиск глобального модуля
    which(name, (err, filepath) => {
      // Получение пути к главному файлу модуля
      resModule(name, { basedir: path.dirname(filepath) || workspace }, (err, res) => {
        if (err) {
          return reject(err);
        }

        resolve(res);
      });
    });
  });
}

module.exports = resolveModule;

Далее, используя эту функцию, подключается главный файл pug-lint и модуль, отвечающий за поиск конфигов.

connection.onInitialize((params) => {
  // Получаем ссылку на главный файл модуля `pug-lint`
  return resolve('pug-lint', params.rootPath, connection)
    .then((filepath) => {
      // Тот самый модуль, отвечающий за поиск конфигов
      const configPath = path.join(path.dirname(filepath), 'config-file.js');
      const Linter = require(filepath);

      configFile = require(configPath);
      linter = new Linter();

      // Просто оставьте это тут и это оповестит клиент о том, что сервер работает
      // в режиме полной синхронизации содержимого документов. Также возможно
      // установить инкрементальную передачу содержимого документов, когда на сервер
      // передаётся только тот фрагмент документа, что был изменён с последней
      // проверки. Об этом придётся почитать в документации
      return {
        capabilities: {
          textDocumentSync: documents.syncKind
        }
      };
    })
    .catch((err) => {
      // Если модуль не найден, то рассказываем пользователю, как его установить
      if (err.code === 'ENOENT') {
        const res = {
          code: 99,
          message: 'Failed to load pug-lint library. Please install pug-lint in your workspace folder using \'npm install pug-lint\' or globally using \'npm install -g pug-lint\' and then press Retry.',
          options: {
            // У ошибки будет отображена кнопка повторого запуска
            // поиска модуля
            retry: true
          }
        };

        return Promise.reject(new ResponseError(res.code, res.message, res.options));
      }

      connection.console.error(err);
    });
});

Выше было описано событие инициализации плагина. Оно выстреливает один раз, причем перед проверкой документов. Однако, поиск может затянуться, поэтому в событии изменения документа следует сделать проверку:

// Слушаем события документов на клиентской стороне:
// открытие, изменение, закрытие.
documents.listen(connection);

// Обработчик события изменения содержимого открытых файлов в проекте на
// клиентской стороне.
documents.onDidChangeContent((event) => {
  // Проверка наличия подключенного модуля поиска конфигурационных файлов. Если
  // его нет, то и нет смысла проверять документ.
  if (configFile) {
    validateSingle(event.document);
  }
});

Поимимо этих событий присутствует возможность обрабатывать изменения файлов, которые были указаны на клиентской строне, как конфигурационные файлы. Никакой магии, просто, если изменится конфигурационный файл, то необходимо перепроверить открытые документы. Точно такая же история в том случае, если были изменены настройки редактора.

connection.onDidChangeConfiguration((params) => {
  editorConfig = params.settings.puglint.config;
  validateMany(documents.all());
});

connection.onDidChangeWatchedFiles(() => {
  validateMany(documents.all());
});

Функции валидации

В коде выше используется две функции валидации: validateMany и validateSingle. Обе функции носят декоративный характер и, при желании, могут быть объединены в одну.

Функция validateMany проверяет все открытые документы и, если были замечены ошибки, собирает их в трекер, который потом отправляется на клиентскую сторону и уже там распределяется по открытым документам:

function validateMany(documents) {
  const tracker = new ErrorMessageTracker();
  documents.forEach((document) => {
    try {
      validate(document);
    } catch (err) {
      tracker.add(getMessage(err, document));
    }
  });

  tracker.sendErrors(connection);
}

Функция validateSingle проверяет лишь один открытый документ и, если была замечена ошибка, отправляет её на клиент:

function validateSingle(document) {
  try {
    validate(document);
  } catch (err) {
    connection.window.showErrorMessage(getMessage(err, document));
  }
}

Функция getMessage просто форматирует ошибку в удобный для пользователя вид. В вашем случае эта функция может делать всё что угодно.

function getMessage(err, document) {
  let result = null;
  if (typeof err.message === 'string') {
    result = err.message.replace(/\r?\n/g, ' ');
  } else {
    result = `An unknown error occured while validating file: ${Files.uriToFilePath(document.uri)}`;
  }

  return result;
}

А теперь перейдём к сердцу валидатора. Эта функция обрабатывает один документ и создаёт массив объектов диагностики на основе предоставленных данных валидатором. Опять таки, код довольно лаконичен, поэтому почитайте комментарии.

function validate(document) {
  // Получаем текст документа и его текущий путь.
  const content = document.getText();
  const uri = document.uri;
  const url = Files.uriToFilePath(uri);

  // Получаем настройки, используя стандартный загрузчик конфигурационных файлов pug-lint
  if (editorConfig) {
    linterOptions = editorConfig;
  } else {
    linterOptions = configFile.load(null, path.dirname(url));
  }

  if (!linterOptions) {
    linterOptions = {};
  }

  // Это относится к pug-lint. Просто подменяем путь, если раширение
  // конфигурационного файла осуществляется из npm-модуля
  const extendPath = linterOptions.extends;
  if (extendPath && path.basename(extendPath) === extendPath) {
    linterOptions.extends = `./node_modules/pug-lint-config-${linterOptions.extends}/index.js`;
  }

  // Это относится к pug-lint. Настраиваем линтер.
  linter.configure(linterOptions);

  // Проверяем документ и получаем от линтера массив найденных проблем. К сожалению, pug-lint
  // не умеет различать уровни ошибок, то есть не имеет понятий «ошибка» и «предупреждение».
  const diagnostics = [];
  const report = linter.checkString(content, url);
  if (report.length > 0) {
    // Проходимся по массиву найденных проблем и отправляем их в массив объектов диагностики.
    report.forEach((problem) => {
      diagnostics.push(makeDiagnostic(problem));
    });
  }

  // Отсылаем полученный массив объектов диагностики обратно клиенту.
  connection.sendDiagnostics({ uri, diagnostics });
}

Однако, стоит обратить внимание на получение конфига для линтера. Недавно я получил запрос на добавление возможности использовать ближайший конфигурационный файл для проверяемого файла. Единственное место, где я могу получить путь до проверяемого файла — функция валидации. Получается некая деоптимизация в угоду потребностей пользователей. Деоптимизация заключается в том, что перед тем как проверять файл, необходимо найти ближайший к этому файлу конфиг, что приводит нежелательному чтению файловой системы.

Осталось рассмотреть создание объекта диагностики:

function makeDiagnostic(problem) {
  // Эти команды относятся к пакету pug-lint. Просто убираем префикс и объединяем сообщение в строку,
  // если сообщение представлено массивом строк.
  const code = problem.code.replace(/(PUG:|LINT_)/g, '');
  const message = (Array.isArray(problem.msg) ? problem.msg.join(' ') : problem.msg).replace('\n', '');

  // Возвращаем объект диагностики.
  return {
    // Уровень проблемы: ошибка или предупреждение. Пакет pug-lint отдаёт только ошибки.
    severity: DiagnosticSeverity.Error,

    // Строка и номер символа, где начинается и заканчивается проблема.
    range: {
      start: {
        line: problem.line - 1,
        character: problem.column
      },
      end: {
        line: problem.line - 1,
        character: problem.column
      }
    },

    // Источник ошибки. Строка приписывается к ошибке слева.
    source: 'puglint',

    // Содержимое проблемы.
    message: `${message} [${code}]`
  };
}

Вот и всё. Примеры плагинов по этой теме, написанные на JavaScript: