Реализация автодополнения кода в Ace Editor
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
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 };
}
Из этой модели следует, что у нас могут быть методы, которые вызываются с передачей параметров или свойства. Свойства могут быть конечными, которые могут вызываться отдельно, а могут быть контейнером для методов и других свойств.
Чтобы более эффективно использовать это дерево, надо будет смаппить его в более плоскую структуру. Разложим его на следующие составляющие:
- completions: { [name: string]: CompletionModel[] } маппинг — название: список подстановок. Список подстановок нужен для дубликатов, чтобы не затерли друг друга. При извлечении значения будем фильтровать по родителю.
- completionsTree: { [name: string]: string[] } маппинг родитель: дети. Разложенное в плоскость дерево для удобства поиска.
- roots: string[] — список рутовых нод, которые будем отдавать при новом вводе.
По-умолчанию, метод getCompletions выплевывает все, что может, а движок уже фильтрует по caption. Но фильтрует среди всех зарегистрированных комплетеров. Таким образом, если просто добавить комплетер к основным, то возникнет проблема. При показе подсказок будут показаны ВСЕ возможные варианты в любом момент времени. Например, у нас есть класс-контейнер WebApi, а у него метод GetRoleById. Тогда с новой строки можно будет написать вызов метода GetRoleById, что не есть правильно. Тут есть два варианта:
- Вставлять полный путь (т.е. WebApi.GetRoleById, вместо GetRoleById)
- Не показывать вложенные ноды, пока до них не дойдет.
Также, надо в нашем комплетере управлять подсказками по-умолчанию (чтобы при обращении через точку к WebApi нельзя было из подсказок добавить if.). И определять, что и в какой момент времени показывать.
Алгоритм получится примерно следующий. Определяем, является ввод новым (нет обращения через точку):
- Если да — показываем контейнеры верхнего уровня и подсказки по-умолчанию.
- Если нет, ищем родителей и по ним определяем, что дальше показывать + показываем текстовые подсказки.
- Так же, если нет, то показываем уже введенные значения. Это надо для того, если уже есть обращения к сущности, то показывались не только метаданные, но и пользовательские переменные.
Для определения родителя, нам потребуется получить текущую строки и столбец. Затем найти слева точку и от нее влево искать слово до разделителя (пробел, точка, скобка).
Для красивого тултипа нам надо будет не только имя метода прокинуть, но и входные аргументы, которые у нас приходят как ключ-значение. Движок ожидает HTML разметку, поэтому простор для фантазии огромен.
В сам метод getDocTooltip передается конкретный элемент completion. У нас в нем записаны входные данные (если есть) и прочие настройки. Алгоритм будет примерно таков:
Если в типе указано snippet и не задан docHTML, тогда считаем, что это простая подсказка (ключевое слово, сниппет и т.д.) и задаем шаблон так, как он задает практически по-умолчанию.
item.docHTML = [
'',
item.caption,
'',
'
',
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 = `${type} ${displayName}
`;
if (description) {
html += `${description}
`;
}
item.docHTML = html;
В итоге получится примерно так.
Для чистого ввода:
Как видим, наши классы отображаются.
Для обращения через точку:
Как видим, после точки у нас отображаются только дочерние методы для класса WebApi.
Если нет метаданных, при обращении через точку
отображаются локальные данные.
Заключение
У нас получилось довольно удобное автодополнение, которое можно использовать при внедрениях без мучений :)
Посмотреть результат можно тут.