Skip to content

Follow-along write up on how to setup a basic monaco editor with LSP support.

Notifications You must be signed in to change notification settings

Barahlush/monaco-lsp-guide

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 

Repository files navigation

monaco with lsp

So, you want to set up a Monaco editor with a language server

Currently, it's not simple to connect an LSP language server to a custom editor (not Neovim and VSCode), the docs are usually sparse and there is a lack of simple and documented projects that implement that. I faced this and decided to document my journey, so I hope this post helps anyone.

In this post, I will cover the following:

  • Basics of monaco-editor
  • Using monaco-editor with several editor windows
  • Using monaco-vscode-api package and setting up the basic language features
  • Adding monaco-languageclient and Python LSP

I tried to document all the sources along the way so you can learn more where needed.

Simple monaco-editor

Let's start with the most simple Monaco setup. I will use vanilla TS with Vite and Bun as package manager, so I hope it will be simple to extrapolate into different frameworks. You can also find a similar example written with React in the official repo.

Feel free to skip this part if you already know about the basic monaco-editor setup.

Initial project

First, let's init a Vite project as described in Bun docs and install monaco-editor:

bun create vite my-monaco-editor
cd my-monaco-editor
bun install
bun add monaco-editor

Then, all we need is a simple html page with one div -- it will be the editor.

<!-- index.html -->
<html>
  <body>
    <div id="editor"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Add a little style:

/* style.css */
body {
  background-color: #242424;
}

#editor {
  margin: 10vh auto;
  width: 720px;
  height: 20vh;
}

Now we can create a Monaco editor instance, which will automatically fill the div with the interactive editor:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';

monaco.editor.create(document.getElementById('editor')!, {
	value: "Hello world!",
});

Voila! The working Monaco editor is here.

Basic Monaco setup

Adding workers

If you look into the DevTools console, you may notice a warning:

Could not create web worker(s). Falling back to loading web worker code in the main thread, which might cause UI freezes. Please see https://github.com/microsoft/monaco-editor#faq

That's because the Monaco editor usually separates text processing and UI interaction into different processes, so they work asynchronously without interfering with each other.

One can do it manually, like this:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';

window.MonacoEnvironment = {
	getWorker(_workerId: any, _label: string) {
		return new editorWorker();
	}
};
monaco.editor.create(document.getElementById('editor')!, {
	value: "Hello world!",
});

We can provide workers for text processing using the window.MonacoEnvironment attribute. The getWorker function receives label - which is the name of a worker required by the editor. Since currently we do not use any languages, the default editorWorker will do.

Now everything works and there are no warnings in the console.

Adding languages

Now, since Monaco is a code editor, let's add some coding language processing. This can be done by adding the language attribute to the options object in the monaco.editor.create call:

// main.ts
monaco.editor.create(document.getElementById('editor')!, {
	value: "console.log('Hello world!');",
	language: "typescript"
});

However, we did not provide the corresponding worker yet. Hopefully, Monaco provides a built-in worker for typescript and javascript:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';

window.MonacoEnvironment = {
	getWorker(_workerId: any, label: string) {
		if (label === 'typescript' || label === 'javascript') {
			return new tsWorker();
		}
		return new editorWorker();
	}
};
monaco.editor.create(document.getElementById('editor')!, {
	value: "console.log('Hello world!');",
	language: "typescript"
});

We need first to check, which worker is requested, and then return the corresponding one. When we create an editor with a given language, Monaco calls getWorker, providing language as a label parameter. This way, we will see a highlighting and built-in LSP in work:

monaco-editor with typescript language features

However, this is true only for some subset of languages, which are built into Monaco by default:

  • json
  • css
  • html
  • typescript
  • javascript

For other languages, Monaco provides fewer features out of the box.

Note

editorWorker is always required for the full functionality of the editor, even if you are not using any languages. So if your editor is only for Python, you can leave just editorWorker in the getWorker function, but still, provide language: "python" when creating an editor:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';

window.MonacoEnvironment = {
	getWorker(_workerId: any, _label: string) {
		return new editorWorker();
	}
};
monaco.editor.create(document.getElementById('editor')!, {
	value: "print('Hello world!')",
	language: "python"
});

Adding more editors

Imagine that you need more than one editor, e.g. for different files. The most straightforward path is to just create another div and call monaco.editor.create one more time:

<!-- index.html -->
<html>
  <body>
    <div id="editor1"></div>
    <div id="editor2"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
/* style.css */
body {
  background-color: #242424;
}

#editor1, #editor2 {
  margin: 10vh auto;
  width: 720px;
  height: 20vh;
}
// main.ts
import './style.css'
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';

window.MonacoEnvironment = {
	getWorker(_workerId: any, label: string) {
		return new editorWorker();
	}
};
monaco.editor.create(document.getElementById('editor1')!, {
	value: "print('Hello world 1!')",
	language: "python"
});

monaco.editor.create(document.getElementById('editor2')!, {
	value: "print('Hello world 2!')",
	language: "python"
});

This will work, but not ideally -- Monaco will try to autocomplete variable names from different editors: Monaco autocomplete from the different editor It's easy to fix, just add the wordBasedSuggestions field set to currentDocument:

monaco.editor.create(document.getElementById('editor2')!, {
	value: "print('Hello world 2!')",
	language: "python",
	wordBasedSuggestions: 'currentDocument'
});

That was the basic possible setup of monaco-editor. If you are using it for TypeScript/CSS/HTML, that may be enough because of the built-in workers. However, if you need it for Python or any other language, you may need to integrate a custom LSP to support advanced features like IntelliSense, default keyword autocompletion, code navigation, linting, etc.

VSCode-API

Let's try to build a Monaco editor with full LSP functionality for Python.

Unfortunately, support for LSP is not built-in natively in Monaco, so you can't just do something like:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import languageClient from 'monaco-lsp' // doesn't exist

window.MonacoEnvironment = {
	getWorker(_workerId: any, _label: string) {
		return new editorWorker();
	}
};

const lsp = new languageClient( // doesn't exist
    serverUri="ws://localhost:5007", 
    rootUri="file:///", 
    languageId="python"
)
monaco.editor.create(document.getElementById('editor')!, {
	value: "print('Hello world!')",
	language: "python",
	lsp: lsp // doesn't work
});

However, most of the language servers work with VSCode out of the box via extensions. And since VSCode is built around Monaco, it's possible to integrate VSCode API (e.g. extensions and other stuff) into Monaco. Including LSP support.

Package monaco-vscode-api does exactly that. But moreover, it in some way redesigns the monaco-editor, making it much more modular (but also more complex).

The documentation on it is not very good (some info in the README and examples in issues and the demo). So, mostly I've figured it all out via trial and error and 3 issues focused on the struggle of implementing the basic functionality:

It changes the code drastically, but some main concepts are the same. Let's start again with a minimal example to demonstrate that.

New Beginning

Let's create a new project in the same way as before, but instead of monaco-editor we will install monaco-vscode-api packages

# before proceeding make sure you are not in an existing project
bun create vite my-monaco-api-editor
cd my-monaco-api-editor
bun install

bun add vscode@npm:@codingame/monaco-vscode-api
bun add monaco-editor@npm:@codingame/monaco-vscode-editor-api
bun add -D @types/vscode

These package names may look weird because they use aliases to be used as enhanced drop-in replacements for vscode and monaco-editor packages. They change their functionality to allow the usage of VSCode services and extensions in Monaco but provide the same interface as the original packages.

Then, again, all we need is a simple html page with one div -- it will be the editor.

<!-- index.html -->
<html>
  <body>
    <div id="editor"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Add a little style:

/* style.css */
body {
  background-color: #242424;
}

#editor {
  margin: 10vh auto;
  width: 720px;
  height: 20vh;
}

As for main.ts, we can start with totally same example, since we've just used drop-in replacement:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';

import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';

window.MonacoEnvironment = {
  getWorker: function (_moduleId, _label) {
	return new editorWorker();
  }
}
monaco.editor.create(document.getElementById('editor')!, {
	value: "Hello world!",
});

In the monaco-vscode-api repo's issues and demo project you will meet the following variant of adding workers:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';

export type WorkerLoader = () => Worker;
const workerLoaders: Partial<Record<string, WorkerLoader>> = {
	TextEditorWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' })
	})
}
window.MonacoEnvironment = {
  getWorker: function (_workerId, label) {
	const workerFactory = workerLoaders[label]
    if (workerFactory != null) {
      return workerFactory()
    }
	throw new Error(`Worker ${label} not found`)
  }
}

monaco.editor.create(document.getElementById('editor')!, {
	value: "Hello world!",
});

It's basically the same, but more strictly checks if the required worker is implemented. Also, the worker initialization is a little different but still functionally the same - TextEditorWorker is a label for the default editorWorker from the previous examples.

There are some nuances to account for in your bundler. They are described in the Troubleshooting section of the repo. Since I'm using Vite here, I'll provide details for Vite users below.

{% details For Vite users %} It uses the import.meta.url base which doesn't work well with Vite out of the box. So if you are using Vite, add this to your vite.config.ts (create it if not yet):

import type { UserConfig } from 'vite'
import importMetaUrlPlugin from '@codingame/esbuild-import-meta-url-plugin'

export default {
   optimizeDeps: {
       esbuildOptions: {
         plugins: [importMetaUrlPlugin]
       }
     }
} satisfies UserConfig

And install the corresponding package

bun add @codingame/esbuild-import-meta-url-plugin

{% enddetails %}

Further, I will use the latter approach to worker initialization so the reader is more used to the notation usually met in the repo. Also, we will know if any worker is not added properly via error in the console.

Adding language highlighting

So, we've built a basic text editor using the new monaco-vscode-api as a drop-in replacement for monaco-editor. Let's try to add Python highlighting. Previously, it was made by adding a language attribute to the monaco.editor.create options object.

However, if we add language, nothing changes and even the highlighting is absent:

import './style.css'
import * as monaco from 'monaco-editor';

import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';

export type WorkerLoader = () => Worker;
const workerLoaders: Partial<Record<string, WorkerLoader>> = {
	TextEditorWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' })
	})
}
window.MonacoEnvironment = {
  getWorker: function (_workerId, label) {
	const workerFactory = workerLoaders[label]
    if (workerFactory != null) {
      return workerFactory()
    }
	throw new Error(`Worker ${label} not found`)
  }
}

monaco.editor.create(document.getElementById('editor')!, {
	value: "print('Hello world!')",
	language: "python"
});

Monaco with no highlighting That's because the default workers which supplied most of the functions don't work in monaco-vscode-api the same way they did in monaco-editor. Now, most of the editor functionality is based on VSCode services - components which provide specific functions, even the basic ones. Here is a large list of services, supported by monaco-vscode-api. To make this functionality work, one needs to add appropriate services manually.

E.g. to support highlighting, now we need to add the following services:

  • Textmate@codingame/monaco-vscode-textmate-service-override
    • Allows to use TextMate grammars to tokenize languages for highlighting.
  • Themes@codingame/monaco-vscode-theme-service-override
  • Languages@codingame/monaco-vscode-languages-service-override
    • Allows to account for the language field and set up TextMate grammars for highlighting and other language-specific functions. And import the corresponding language and theme extensions (see below).

Adding services is simple, install the corresponding package from the list and pass the ...get*ServiceOverride() into initialize function from vscode/services before creating editors. Let's try this.

Installing packages for services and extensions:

# split into several commands for readability
bun add @codingame/monaco-vscode-textmate-service-override 
bun add @codingame/monaco-vscode-theme-service-override 
bun add @codingame/monaco-vscode-languages-service-override
bun add @codingame/monaco-vscode-python-default-extension
bun add @codingame/monaco-vscode-theme-defaults-default-extension

Adding services to the editor:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';

// importing installed services
import { initialize } from 'vscode/services'
import getLanguagesServiceOverride from "@codingame/monaco-vscode-languages-service-override";
import getThemeServiceOverride from "@codingame/monaco-vscode-theme-service-override";
import getTextMateServiceOverride from "@codingame/monaco-vscode-textmate-service-override";

// adding worker
export type WorkerLoader = () => Worker;
const workerLoaders: Partial<Record<string, WorkerLoader>> = {
	TextEditorWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' })
	})
}
window.MonacoEnvironment = {
  getWorker: function (_workerId, label) {
	const workerFactory = workerLoaders[label]
    if (workerFactory != null) {
      return workerFactory()
    }
	throw new Error(`Worker ${label} not found`)
  }
}

// adding services
await initialize({
    ...getTextMateServiceOverride(),
    ...getThemeServiceOverride(),
    ...getLanguagesServiceOverride(),
});

monaco.editor.create(document.getElementById('editor')!, {
	value: "print('Hello world!')",
	language: "python"
});

This will work without any warnings or errors, but the highlighting is not there yet. To add it, we need to finally integrate

  1. Python language default extension which will provide Python grammar;
  2. TextMate worker which will tokenize the code based on the grammar;
  3. Theme, so different keywords can have unique colors.

Python language and theme are both VSCode extensions. Installation of extensions described in detail in README.md. Fortunately, for default VSCode extensions like the ones we need, there are prebuilt packages by the repo's authors:

  • @codingame/monaco-vscode-python-default-extension for Python
  • @codingame/monaco-vscode-theme-defaults-default-extension for default VSCode themes Adding them to the project is as simple as installing the packages and adding the corresponding imports to the beginning of the main.ts.
// main.ts
import '@codingame/monaco-vscode-python-default-extension';
import "@codingame/monaco-vscode-theme-defaults-default-extension";

... // rest of the code

To integrate the TextMate worker, we need to add it to the workerLoaders map:

// main.ts
...

const workerLoaders: Partial<Record<string, WorkerLoader>> = {
	TextEditorWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' }),
	TextMateWorker: () => new Worker(new URL('@codingame/monaco-vscode-textmate-service-override/worker', import.meta.url), { type: 'module' })
}
...

{% details How to find necessary services and/or extensions? %} You can find a full list of services and extensions here: https://www.npmjs.com/search?q=%40codingame%2Fmonaco-vscode-*-default-extension There is no full documentation, so to find out what you need you usually look into issues/demo/other projects using monaco-vscode-api, and copy that, or intuitively add services/extensions based on their name until it is not working. At least, I've not found a better way yet. {% enddetails %} And voila, the final code with Python highlighting support:

import '@codingame/monaco-vscode-python-default-extension';
import "@codingame/monaco-vscode-theme-defaults-default-extension";

import './style.css'
import * as monaco from 'monaco-editor';
import { initialize } from 'vscode/services'

 
import getLanguagesServiceOverride from "@codingame/monaco-vscode-languages-service-override";
import getThemeServiceOverride from "@codingame/monaco-vscode-theme-service-override";
import getTextMateServiceOverride from "@codingame/monaco-vscode-textmate-service-override";

export type WorkerLoader = () => Worker;
const workerLoaders: Partial<Record<string, WorkerLoader>> = {
	TextEditorWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' }),
	TextMateWorker: () => new Worker(new URL('@codingame/monaco-vscode-textmate-service-override/worker', import.meta.url), { type: 'module' })
}

window.MonacoEnvironment = {
  getWorker: function (_moduleId, label) {
	console.log('getWorker', _moduleId, label);
	const workerFactory = workerLoaders[label]
    if (workerFactory != null) {
      return workerFactory()
    }
	throw new Error(`Worker ${label} not found`)
  }
}

await initialize({
	...getTextMateServiceOverride(),
	...getThemeServiceOverride(),
	...getLanguagesServiceOverride(),
});

monaco.editor.create(document.getElementById('editor')!, {
	value: "import numpy as np\nprint('Hello world!')",
	language: 'python'
});

Note: afaik, to turn the highlighting on you may need to manually edit your code (e.g. add and remove a whitespace), so the TextMate worker starts to work. I may update this later if I find a robust solution.

Introducing language server

Now, let's finally add a language server. For this one, we will need to use a cousin package of monaco-vscode-api called monaco-languageclient which actively utilizes the former.

We also will need a language server itself.

{% details Note on LSP servers %} Usually when using VSCode, you just select a language and install the corresponding language server extension from Marketplace, e.g. Pyright of Ruff for Python. Under the hood, most of these VSCode language server extensions utilize vscode-languageclient api. The API allows to launch LSP server in several ways, e.g. as a node module running in runtime provided by VSCode itself, or as a child process via runnable command. You can take a look at the Pylyzer Python LSP extension to see an example of usage of the API.

Note that to use it, you need a runtime that has access to your files.

There is a possibility to add a VSCode server to your Monaco project and use it to launch language servers, however, it adds additional complexity and dependency. In this guide, I will avoid it.

There are other ways to run a Language Server, e.g. one can create a new language server or a wrapper for an existing one with pygls, to run it as a Python process providing web socket server. Here is a great guide with an introduction to language servers and Monaco language client. Another similar option but for Rust is tower-lsp. {% enddetails %} Let's go with the simplest way for Python -- use python-lsp-server, which provides web socket LSP server with all bells and whistles out of the box.

The following example will be based on a bare client example implementation from the monaco-languageclient repo.

To proceed, we will need to install two additional packages:

bun add vscode-ws-jsonrpc
bun add monaco-languageclient

Then, let's create a file lsp-client.ts. Here we will write initialization functions for the LSP client. There we will handle a web socket connection with the server.

// lsp-client.ts
import { WebSocketMessageReader } from 'vscode-ws-jsonrpc';
import { CloseAction, ErrorAction, MessageTransports } from 'vscode-languageclient/browser.js';
import { WebSocketMessageWriter } from 'vscode-ws-jsonrpc';
import { toSocket } from 'vscode-ws-jsonrpc';
import { MonacoLanguageClient } from 'monaco-languageclient';

export const initWebSocketAndStartClient = (url: string): WebSocket => {
    const webSocket = new WebSocket(url);
    webSocket.onopen = () => {
	    // creating messageTransport
        const socket = toSocket(webSocket);
        const reader = new WebSocketMessageReader(socket);
        const writer = new WebSocketMessageWriter(socket);
        // creating language client
        const languageClient = createLanguageClient({
            reader,
            writer
        });
        languageClient.start();
        reader.onClose(() => languageClient.stop());
    };
    return webSocket;
};
const createLanguageClient = (messageTransports: MessageTransports): MonacoLanguageClient => {
    return new MonacoLanguageClient({
        name: 'Sample Language Client',
        clientOptions: {
            // use a language id as a document selector
            documentSelector: ['python'],
            // disable the default error handler
            errorHandler: {
                error: () => ({ action: ErrorAction.Continue }),
                closed: () => ({ action: CloseAction.DoNotRestart })
            }
        },
        // create a language client connection from the JSON RPC connection on demand
        connectionProvider: {
            get: async (_encoding: string) => messageTransports
        }
    });
};

The key concept here is the messageTransports parameter in the createLanguageClient function. It is a pair of initialized web socket reader and writer that allow to communicate with the server.

Now, all we need to make it work is to run the initWebSocketAndStartClient function from main.ts providing url and port of the web socket language server:

import '@codingame/monaco-vscode-python-default-extension';
import "@codingame/monaco-vscode-theme-defaults-default-extension";

import './style.css'
import * as monaco from 'monaco-editor';
import { initialize } from 'vscode/services'

// we need to import this so monaco-languageclient can use vscode-api
import "vscode/localExtensionHost";
import { initWebSocketAndStartClient } from 'lsp-client'

// everything else is the same except the last line
import getLanguagesServiceOverride from "@codingame/monaco-vscode-languages-service-override";
import getThemeServiceOverride from "@codingame/monaco-vscode-theme-service-override";
import getTextMateServiceOverride from "@codingame/monaco-vscode-textmate-service-override";

export type WorkerLoader = () => Worker;
const workerLoaders: Partial<Record<string, WorkerLoader>> = {
	TextEditorWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' }),
	TextMateWorker: () => new Worker(new URL('@codingame/monaco-vscode-textmate-service-override/worker', import.meta.url), { type: 'module' })
}

window.MonacoEnvironment = {
  getWorker: function (_moduleId, label) {
	console.log('getWorker', _moduleId, label);
	const workerFactory = workerLoaders[label]
    if (workerFactory != null) {
      return workerFactory()
    }
	throw new Error(`Worker ${label} not found`)
  }
}

await initialize({
	...getTextMateServiceOverride(),
	...getThemeServiceOverride(),
	...getLanguagesServiceOverride(),
});

monaco.editor.create(document.getElementById('editor')!, {
	value: "import numpy as np\nprint('Hello world!')",
	language: 'python'
});

// start web socket lsp client on port 5007 
// (you can choose any port, just make sure the server uses the same)
initWebSocketAndStartClient("ws://localhost:5007/")

Now, just install and run python-lsp-server on the port you selected:

pip install python-lsp-server
pylsp --ws --port 5007

And here we go:

Monaco with LSP

Where to go next

  • You can reduce the amount of boilerplate (e.g. adding services for basic functionality like themes, highlighting, etc.) by using monaco-editor-wrapper
  • You can dive deeper into the concept of models to better control LSP features between files in your project
  • You can try to set up some nodejs-based language server like pyright or basedpyright.
  • Look at the examples in monaco-languageclient to learn more.

About

Follow-along write up on how to setup a basic monaco editor with LSP support.

Topics

Resources

Stars

Watchers

Forks