In order to support the extensible nature of reStructuredText and Sphinx, Esbonio itself is structured so that it can be easily extended. This section of the documentation outlines the server’s architecture and how you can write your own extensions.


Language Feature #1 Language Feature #2 Language Server Language Client LSP Protocol Engine ..... Language Feature #N

A rough sketch of how the language server(s) in Esbonio are architected.

Language Server#

A language server is a subclass of the LanguageServer class provided by the pygls library.

In Esbonio, all the features you would typically associate with a language server, e.g. completions are not actually implemented by the language server. These features are provided through a number of “language features” (see below). Instead a language server acts a container for all the active language features and provides an API they can use to query aspects of the environment.

Esbonio currently provides two language servers

Language Feature#

Language features are subclasses of LanguageFeature. They are typically based on a single aspect of reStructuredText (e.g. Roles).

Language Features (where it makes sense) should be server agnostic, that way the same features can be reused across different envrionments.


For lack of a better name… an “engine” is responsible for mapping messages from the LSP Protocol into function calls within the language server. Unlike the other components of the architecture, an “engine” isn’t formally defined and there is no API to implement. Instead it’s just the term used to refer to all the @server.feature() handlers that define how LSP messages should be handled.

Currently we provide just a single “engine” create_language_server(). As an example, here is how it handles textDocument/completion requests.

        trigger_characters=[">", ".", ":", "`", "<", "/"], resolve_provider=True
def on_completion(ls: RstLanguageServer, params: CompletionParams):
    uri = params.text_document.uri
    pos = params.position

    doc = ls.workspace.get_document(uri)
    line = ls.line_at_position(doc, pos)
    location = ls.get_location_type(doc, pos)

    items = []

    for name, feature in ls._features.items():
        for pattern in feature.completion_triggers:
            for match in pattern.finditer(line):
                if not match:

                # Only trigger completions if the position of the request is within
                # the match.
                start, stop = match.span()
                if start <= pos.character <= stop:
                    context = CompletionContext(
                    ls.logger.debug("Completion context: %s", context)

                    for item in feature.complete(context):
                        item.data = {"source_feature": name, **(item.data or {})}  # type: ignore

    return CompletionList(is_incomplete=False, items=items)

There is nothing in Esbonio that would prevent you from writing your own if you so desired.

Extension Module#

Ordinary Python modules are used to group related functionality together. Taking inspiration from how Sphinx is architected, language servers are assembled by passing the list of modules to load to the create_language_server(). This assembly process calls any functions with the name esbonio_setup allowing for LanguageFeatures to be configured and loaded into the server.

Startup Module#

As mentioned above, language servers are assembled and this is done inside a startup module. A startup module in Esbonio is any Python script or module runnable by a python -m <modulename> command that results in a running language server. A good use case for a custom entry point would be starting up a language server instance pre configured with all the extensions required by your project.