Implementing code completion in Ace Editor

6 min


Ace (Ajax.org Cloud9 Editor) is a popular code editor for web applications. It has both pros and cons. One of the great advantages of the library is the ability to use custom snippets and tooltips. However, this is not the most trivial task, nor is it very well documented. We actively use the editor in our products and decided to share the recipe with the community.

Foreword

We use Ace to edit notification templates and to edit custom functions in Python, designed to be called from the business process engine that we wrote about earlier.

At one time, when we had a question about choosing an editor, we considered three options: Ace, Monaco, CodeMirror. We already had experience with CodeMirror, and it turned out to be very inconvenient. Monaco, of course, is cool, but Ace seemed more functional at that time.

Out of the box, Ace supports snippets for a specific language, if you connect them. These are both basic constructs and keywords (such as if-else, try-except, class, def, etc). This is certainly convenient, but how to tell the user about other types available in the context of script execution? The first option is documentation (which no one reads) But this method has several disadvantages. Among them – obsolescence, typos, constant switching between the documentation and the editor. Therefore, it was decided to integrate our tips into the editor.

Recipe

So, for starters, plug Ace into our application. You can do this in any way convenient for you, and since we have a frontend on Angular, for convenience, we’ll install ng2-ace-editor and all the necessary dependencies.

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

And add to the template.

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 sysnnprint("test")';
  options = {
    enableBasicAutocompletion: true,
    enableLiveAutocompletion: true,
    enableSnippets: true
  };

  constructor() { }
}

We will not dwell on each parameter in detail, you can read about them in the documentation ace and ng2-ace-editor.

Here you can be surprised, because we are talking about ace editor, but some brace. That’s right, brace is an ace browser browser. As stated in the readme, it is needed for integration into the browser, in order to be included in the bundle and not to put the same ace on the server.

Tips

“EnableSnippets” includes built-in snippets for the selected language, if you load the corresponding module.

import 'brace/snippets/python'

Check what works.

Great, keywords, basic snippets are displayed. Local data too.

There is practically no word in the documentation about the data substitution model, except for an example on plunkerwhere four fields are used: name, value, score, meta. It is not clear what is what. Yes, and the example does not work. But it is clear that the compiler itself must contain a method

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

where in callback need to pass a list of possible substitutions. Editor is the instance of the entire editor. Session – current session. Pos – apparently, the position where the call of the completer has worked and prefix – entered characters.

Let’s open the place where the completers are registered ace / ext / language_tools.js. And we see that the completor may have another method

getDocTooltip: function(item)

inside which the value for the field is set innerHTML to display information about the object in the form of a beautiful tooltip.

As a result, the completter interface:

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;

}

About callback: what completions understandably. But there is data – not really, because everywhere it is transmitted there null. So let it be so, apparently, we don’t need it 🙂

In the process of debugging, it becomes clear that the engine is looking for the caption field. And the fields are displayed in the list. Name and Meta. The substitution may have a value in the snippet field, then the substitution will work just like a snippet, and not just like text. Empirically, we find out that a snippet can contain variables that can be replaced. Their syntax is as follows: “{1: variable}”. Where 1 – ordinal index of the substitution (yes, the count starts from 1), and variable – The name of the default lookup.

The final model we get is something like this:

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

To display a beautiful tooltip, add a field to the model Inputparameters. This is necessary in order to display these same parameters, as in a full-fledged code editor 🙂

Metadata model

From the server we receive data, approximately in the following form:

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

It follows from this model that we can have methods that are called with passing parameters or properties. Properties can be finite, which can be called separately, or can be a container for methods and other properties.

To use this tree more efficiently, you will need to map it into a flatter structure. We decompose it into the following components:

  1. completions: { [name: string]: CompletionModel[] } mapping – name: list of permutations. A list of substitutions is needed for duplicates so as not to overwrite each other. When extracting the values, we will filter by parent.
  2. completionsTree: { [name: string]: string[] } mapping parent: children. A tree laid out in a plane for easy search.
  3. roots: string[] – a list of root nodes that we will give back on a new entry.

By default, the method getCompletions spits out everything that it can, and the engine already filters by caption. But it filters among all registered completers. Thus, if you simply add the complector to the main ones, then a problem will arise. When showing prompts, ALL possible options will be shown at any given time. For example, we have a container class Webpi, and he has a method GetRoleById. Then from a new line it will be possible to write a method call GetRoleByIdthat is not right. There are two options:

  1. Insert full path (i.e. WebApi.GetRoleByIdinstead GetRoleById)
  2. Do not show nested nodes until it reaches them.

Also, it is necessary to manage prompts in our completer by default (so that when accessing through a point to Webpi it was impossible to add from the tips if. ) And determine what and at what point in time to show.

The algorithm is approximately the following. We determine whether the input is new (no access through a point):

  • If Yes – show top-level containers and hints by default.
  • If not, we’re looking for parents and using them we determine what to show next + we show text tips.
  • Also if not, then show already entered values. This is necessary if there are already calls to the entity, then not only metadata is displayed, but also user variables.

To determine the parent, we need to get the current row and column. Then find the point to the left and from the left search for the word to the separator (space, dot, bracket).

For a beautiful tooltip, we will need not only to throw the name of the method, but also the input arguments that come to us as key-value. The engine expects HTML markup, so the scope for imagination is huge.

In the method itself getDocTooltip specific element passed completion. We have input data (if any) and other settings written in it. The algorithm will be something like this:

If type is specified snippet and not set docHTML, then we believe that this is a simple hint (keyword, snippet, etc.) and set the template as it sets almost by default.

  item.docHTML = [
          '',
          item.caption,
          '',
          '
', item.snippet ].join('');

If the object has input parameters, then it is already more difficult. It is necessary to collect the input parameters, the full path, add a description and assemble the 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;

The result will be something like this.

For clean input:

As you can see, our classes are displayed.

To access through a point:

As you can see, after the point we have only child methods for the class Webpi.

If there is no metadata, when accessing through a point

local data is displayed.

Conclusion

We got a pretty convenient auto-completion, which can be used with the implementation without pain 🙂

You can see the result here.


One Comment

Leave a Reply

  1. Hi,

    Very nice article. I am trying to include ace editor into Java project but could you tell me if it can be done in Javascript please?