Реализация автодополнения кода в 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

<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.

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

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

Заключение

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

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

ссылка на оригинал статьи https://habr.com/ru/company/avanpost/blog/486240/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *