Skip to content

Syntax validation using workers

Samuel Pastva edited this page Aug 15, 2018 · 5 revisions

In order to perform syntax checking in Ace, you typically need a web worker which will do the heavy lifting asynchronously in the background. Ace provides web workers for several programming languages, but building a custom worker is not exactly simple, because you have to integrate it into Ace and then build it from source. This is straightforward for Javascript workers, but integrating into Kotlin pipeline can be difficult.

To make this process a little more accessible, the worker module provides the necessary Ace classes in a separate Javascript file, which webpack loads before your main worker script. This gives you access to the standard Ace classes like Mirror and Document which you can use to communicate with the editor.

Project structure

Remember that the worker always runs in a separate context and therefore you should not mix worker and web code in one module. What you need to do is to create one module with your worker code and then another for your web related code. If you have some code you want to share between those two (such as the parser or the tokenizer), put it into a separate module (ideally, this is a pure Kotlin module, so that you can then use it on backend too), ending up with a structure similar to this:

common: (pure Kotlin)
   depends on whatever you need...
my-worker: (Kotlin/JS)
   implementation common
   implementation kotlin-ace-worker
my-web: (Kotlin/JS)
   implementation common
   implementation kotlin-ace-web

Finally, we have to tell webpack that the worker module will be running as a web worker and so the Ace classes should not be loaded from the web implementation. This is done using a custom config file:

var config = {
   ...
   "target": "webworker",
   ...
};

The full example of how to set-up your worker related classes is in the demo and demo-worker modules.

Note that Ace starts web workers as blob, so if you want to reference some other dependencies/scripts in your worker module, you have to bundle them together (preferred, shown in the demo), or load them using a fully quantified URL (this can't be used if you are running the editor as a local file://).

Creating a web worker

Once the modules are created, you can define your first web worker. You will want to extend the Mirror class, as it gives you access to a copy of the Document managed by the live editor:

class DemoWorker(sender: Sender) : Mirror(sender) {
   init { setTimeout(2000) } // set the refresh interval
   
   override fun onUpdate() {
      // Perform syntax validation - using document to access the current text in the editor
      // and sender.emit to send the results back to the main context.
   }
}

fun main(args: Array<String>) {
   // You also have to register the worker class in your main method, otherwise
   // Ace won't know how to find it.
   DemoWorker::class.js.register()
}

The full example is again included in the demo module. Note that here, we perform a very simple task (checking the parenthesis match), so we don't have any common module.

WARNING: The data you send using sender.emit will be serialised and deserialised in order to move it between contexts. Kotlin's meta-information about classes will be lost during this process. Therefore, you can only send primitive types (including Arrays) if you want to preserve type safety. You can also send structured data, but you have to treat the data as dynamic objects in your web module because the type info is missing, so Kotlin can't perform safe casts on it.

Starting a web worker

To start the worker, you have to override the createWorker method in your custom TextMode and create a WorkerClient which will run and communicate with our worker. To make this process more intuitive, there is a convenience method named startWorkerFromBundle which you can use to start a worker created as a webpack bundle:

override fun createWorker(session: EditSession): WorkerClient? {
   // start client
   val client = startWorkerFromBundle("DemoWorker", "worker.bundle.js")
   return client
}

This will load the given JS bundle and then start a worker with class name "DemoWorker" (assuming it is properly registered).

Communication

In order to listen to events sent from the worker context, you simply need to listen to events emitted on the corresponding WorkerClient. Ace will also wrap your data into a custom event object, which is described by the WorkerClient.Event interface:

// In worker context:
sender.emit("isValid", true)

// In main context:
client.on<WorkerClient.Event<Boolean>>("isValid") { event -> 
   if (event.data) ... // do something
}

Remember that after deserialisation, only Javascript fields are preserved (all methods or getters/setters are lost). You can therefore safely transfer only primitive data types (and arrays). However, you can always treat your data as dynamic objects on the receiving end. Just make sure the fields have a specified JsName.

Alternatively, you can also use external interfaces to give the illusion of type safety. While this approach still isn't completely safe, it reduces the room for error and allows you to write code like this:

sender.emit("errors", arrayOf(makeGutterAnnotation(1, text="foo"), makeGutterAnnotation(2, text="goo")))

client.on<WorkerClient.Event<Array<GutterAnnotation>>("errors") { event -> 
   session.setAnnotations(event.data)
}

The whole process relies on the implementation of makeGutterAnnotation which, instead of creating a gutter annotation directly, creates an anonymous object which is then cast to gutter annotation. This ensures that the important properties are created as fields instead of getters and the object can be safely serialised and deserialised. You can see the implementation here.

Clone this wiki locally