Ace (Ajax.org Cloud9 Editor) — популярный редактор кода для веб-приложений. У него есть как плюсы, так и минусы. Одно из больших преимуществ библиотеки — возможность использования пользовательских сниппетов и подсказок. Однако, это не самая тривиальная задача, к тому же не очень хорошо документированная. Мы активно используем редактор в своих продуктах и решили поделиться рецептом с сообществом.



Предисловие


Мы используем Ace для редактирования шаблонов уведомлений и для редактирования пользовательских функций на Python, предназначенных для вызова из движка бизнес-процессов, о котором мы писали ранее.


В свое время, когда у нас встал вопрос выбора редактора, мы рассматривали три варианта: Ace, Monaco, CodeMirror. С CodeMirror у нас уже был опыт, и он оказался очень неудобным. Monaco, конечно, крут, но Аce показался более функциональным на тот момент.


Из коробки Ace поддерживает сниппеты для конкретного языка, если их подключить. Это и базовые конструкции и ключевые слова (такие, как if-else, try-except, class, def, etc). Это безусловно удобно, но как сообщать пользователю о прочих типах, доступных в контексте исполнения скрипта? Первый вариант — документация (которую никто не читает). Но у этого метода есть ряд недостатков. Среди них — устаревание, опечатки, постоянное переключение между документацией и редактором. Поэтому было решено интегрировать наши подсказки в редактор.


Рецепт


Итак, для начала, подключим Ace в наше приложение. Вы можете сделать это любым удобным для вас способом, а так как у нас фронтенд на Angular, для удобства, установим ng2-ace-editor и все необходимые зависимости.


npm install --save ng2-ace-editor brace ace-builds

И добавим в шаблон.


Editor.component.html


<ace-editor
  id="editor"
  #scriptEditor
  [options]="options"
  [autoUpdateContent]="true"
  [mode]="'python'"
  [theme]="'github'"
  [(text)]="script"
  [style.height.px]="600"
></ace-editor>

editor.component.ts


import { Component } from '@angular/core';
import * as ace from 'brace';
// для посветки синтаксиса
import 'brace/mode/python';
// для подсказок
import 'brace/snippets/python';
// цветовая тема
import 'brace/theme/github';
import 'brace/ext/language_tools';

@Component({
  selector: 'app-editor',
  templateUrl: './editor.component.html',
  styleUrls: ['./editor.component.css']
})
export class EditorComponent {
  script = 'import sys\n\nprint("test")';
  options = {
    enableBasicAutocompletion: true,
    enableLiveAutocompletion: true,
    enableSnippets: true
  };

  constructor() { }
}

Подробно останавливаться на каждом параметре не будем, о них можно прочитать в документации ace и ng2-ace-editor.


Тут можно удивиться, ведь речь идет об ace editor, а импортируется какой-то brace. Все так, brace является браузерной адаптацией ace. Как сказано в ридми, он нужен для интеграции в браузер, дабы на включать в бандл и не ставить на сервак тот же ace.


Подсказки


“enableSnippets” включает встроенные сниппеты для выбранного языка, если подгрузить соответствующий модуль.


import 'brace/snippets/python'

Проверим, что работает.




Отлично, ключевые слова, базовые сниппеты отображаются. Локальные данные тоже.


В документации нет практически ни слова о модели данных подстановок, кроме примера на plunker, где используются четыре поля: name, value, score, meta. Не совсем понятно, что есть что. Да и пример не работает. Но понятно, что сам комплетер должен содержать метод


getCompletions: function(editor, session, pos, prefix, callback)

где в callback надо передать список возможных подстановок. Editor является инстансом всего редактора. Session — текущая сессия. Pos — видимо, позиция, где сработал вызов комплетера и prefix — введенные символы.


Откроем место, где регистрируются комплетеры ace/ext/language_tools.js. И видим, что у комплетера может быть еще один метод


getDocTooltip: function(item)

внутри которого устанавливается значение для поля innerHTML для вывода информации об объекте в виде красивого тултипа.


В итоге, интерфейс комплетера:


export interface ICompleter {

  getCompletions(
    editor: ace.Editor,
    session: ace.IEditSession,
    pos: ace.Position,
    prefix: string,
    callback: (data: any | null, completions: CompletionModel[]) => void
  ): void;

  getTooltip(item: CompletionModel): void;

}

Про callback: что такое completions понятно. А вот что есть data — не совсем, ибо везде там передается null. Так что пусть будет так, видимо, нам это не понадобится :)


В процессе дебага становится понятно, что движок ищет по полю caption. А отображаются в списке поля Name и Meta. У подстановки может присутствовать значение в поле snippet, тогда подстановка сработает именно как сниппет, а не просто как текст. Опытным путем выясняем, что сниппет может содержать переменные, которые можно заменить. Синтаксис их таков: “{1:variable}”. Где 1 — порядковый индекс подстановки (да, отсчет начинается с 1), а variable — название подстановки по-умолчанию.


Итоговая модель у нас получится примерно такая:


export interface CompletionModel {
  caption: string;
  description?: string;
  snippet?: string;
  meta: string;
  type: string;
  value?: string;
  parent?: string;
  docHTML?: string;
  // Входные параметры. Где ключ - имя параметры, значение - тип
  inputParameters?: { [name: string]: string };
}

Для вывода красивого тултипа добавим в модель поле InputParameters. Это надо для того, чтобы вывести эти самые параметры, как в полноценном редакторе кода :)


Модель метаданных


От сервера получаем данные, примерно в таком виде:


export interface MetaInfoModel {
  // название сущности
  Name: string;
  // описание
  Description: string;
  // возвращаемый тип значения
  Type: string;
  // список вложенных элементов
  Children: MetaInfoModel[];
  // входные параметры, если это метод
  InputParameters?: { [name: string]: string };
}

Из этой модели следует, что у нас могут быть методы, которые вызываются с передачей параметров или свойства. Свойства могут быть конечными, которые могут вызываться отдельно, а могут быть контейнером для методов и других свойств.


Чтобы более эффективно использовать это дерево, надо будет смаппить его в более плоскую структуру. Разложим его на следующие составляющие:


  1. completions: { [name: string]: CompletionModel[] } маппинг — название: список подстановок. Список подстановок нужен для дубликатов, чтобы не затерли друг друга. При извлечении значения будем фильтровать по родителю.
  2. completionsTree: { [name: string]: string[] } маппинг родитель: дети. Разложенное в плоскость дерево для удобства поиска.
  3. roots: string[] — список рутовых нод, которые будем отдавать при новом вводе.

По-умолчанию, метод getCompletions выплевывает все, что может, а движок уже фильтрует по caption. Но фильтрует среди всех зарегистрированных комплетеров. Таким образом, если просто добавить комплетер к основным, то возникнет проблема. При показе подсказок будут показаны ВСЕ возможные варианты в любом момент времени. Например, у нас есть класс-контейнер WebApi, а у него метод GetRoleById. Тогда с новой строки можно будет написать вызов метода GetRoleById, что не есть правильно. Тут есть два варианта:


  1. Вставлять полный путь (т.е. WebApi.GetRoleById, вместо GetRoleById)
  2. Не показывать вложенные ноды, пока до них не дойдет.

Также, надо в нашем комплетере управлять подсказками по-умолчанию (чтобы при обращении через точку к WebApi нельзя было из подсказок добавить if. ). И определять, что и в какой момент времени показывать.


Алгоритм получится примерно следующий. Определяем, является ввод новым (нет обращения через точку):


  • Если да — показываем контейнеры верхнего уровня и подсказки по-умолчанию.
  • Если нет, ищем родителей и по ним определяем, что дальше показывать + показываем текстовые подсказки.
  • Так же, если нет, то показываем уже введенные значения. Это надо для того, если уже есть обращения к сущности, то показывались не только метаданные, но и пользовательские переменные.

Для определения родителя, нам потребуется получить текущую строки и столбец. Затем найти слева точку и от нее влево искать слово до разделителя (пробел, точка, скобка).


Для красивого тултипа нам надо будет не только имя метода прокинуть, но и входные аргументы, которые у нас приходят как ключ-значение. Движок ожидает HTML разметку, поэтому простор для фантазии огромен.


В сам метод getDocTooltip передается конкретный элемент completion. У нас в нем записаны входные данные (если есть) и прочие настройки. Алгоритм будет примерно таков:


Если в типе указано snippet и не задан docHTML, тогда считаем, что это простая подсказка (ключевое слово, сниппет и т.д.) и задаем шаблон так, как он задает практически по-умолчанию.


  item.docHTML = [
          '<b>',
          item.caption,
          '</b>',
          '<hr></hr>',
          item.snippet
        ].join('');

Если в объекте есть входные параметры, то уже сложнее. Надо собрать входные параметры, полный путь, добавить описание и собрать HTML.


// собираем входные параметры
      let signature = Object.keys(item.inputParameters)
        .map(x => `${x} ${item.inputParameters[x]}`)
        .join(', ');

      if (signature) {
        signature = `(${signature})`;
      }

      const path = [];
      // Соберём путь до текущего метода
      if (item.parent) {
        let parentId = item.parent;
        while (parentId) {
          path.push(parentId);
          parentId = this.completions[parentId][0].parent;
        }
      }
      const displayName =
        [...path.reverse(), item.caption].join('.') + signature;
      let type = item.type;
      if (item.meta === 'class') {
        type = 'class';
      }

      const description = item.description || '';
      let html = `<b>${type} ${displayName}</b><hr>`;
      if (description) {
        html += `<p style="max-width: 400px; white-space: normal;">${description}</p>`;
      }
      item.docHTML = html;

В итоге получится примерно так.


Для чистого ввода:




Как видим, наши классы отображаются.

Для обращения через точку:



Как видим, после точки у нас отображаются только дочерние методы для класса WebApi.

Если нет метаданных, при обращении через точку



отображаются локальные данные.

Заключение


У нас получилось довольно удобное автодополнение, которое можно использовать при внедрениях без мучений :)


Посмотреть результат можно тут.