Skip to main content

LSP/JSONRPC multiplexer for connecting one LSP client to multiple servers

Project description

Tests PyPI version

rassumfrassum

Connect an LSP client to multiple LSP servers.

The rass program, the main entry point, behaves like an LSP stdio server, so clients think they are talking to single LSP server, even though they are secretly talking to many. Behind the scenes more stdio LSP server subprocesses are spawned. Zero dependencies beyond Python standard library (3.10+).

demo

Setup

Install the rass tool and some language servers, say, Python's ty and ruff:

pip install rassumfrassum ty ruff

Now teach your LSP client to call rass:

  • In Emacs's Eglot, find a Python file in a project and C-u M-x eglot RET rass python RET.

  • In vanilla Neovim, use this snippet (briefly tested with nvim --clean -u snippet.lua)

vim.lsp.config('rass-python', {
   cmd = {'rass','python'},
   filetypes = { 'python' },
   root_markers = { '.git', },
})
vim.lsp.enable('rass-python')

Command line

rass python is the equivalent of

rass -- ty server -- ruff server

which works just as well. You can compose as many servers as you want this way. See rass --help for more help. The rass program executable is installed by the package manager.

If you need to run this from a Git checkout with no installation at all:

export PYTHONPATH=$PWD/src
python3 -m rassumfrassum -- ty server -- ruff server

Presets

Presets give you a uniform way to start typical sets of language servers for a given language, while being flexible enough for tweaking. Many presets are simple and are just Python files with a servers() function that returns a list of server commands.

So-called hooking presets hook into LSP messages to hide the typical initialization/configuration pains from clients, see vue.py.

Using Presets

The bundled python preset runs ty and ruff:

rass python

You can add more servers on top of a preset using -- separators. For example, to add codebook for spell checking:

rass python -- codebook-lsp server

Bundled presets

It's early days and Rassumfrassum bundles only a few of these. Some are very simple, and some are slightly more complex.

  • tyruff: for ty + ruff (simple)

  • basedruff: for basedpyright-langserver + ruff (simple)

  • tslint: for typescript-language-server + eslint (complex)

  • tsbiome: for typescript-language-server + biome (simple)

  • vuetail: for vue-language-server + tailwindcss-language-server (complex)

The "complex" presets use special hooks of the 'LspLogic' class to massage the exchanged messages. The servers in question (usually the JS-land ones) unfortunately weren't designed to startup without copious amounts of handholding given to them by specific clients (usually VSCode).

User Presets

You can create your own presets or override bundled ones. Rass searches these locations in order:

  1. $XDG_CONFIG_HOME/rassumfrassum/ (if XDG_CONFIG_HOME is set)
  2. ~/.config/rassumfrassum/ (default)
  3. ~/.rassumfrassum/ (legacy)
  4. Bundled presets directory (last resort)

To use pylsp and ruff create, say, ~/.config/rassumfrassum/pylspruff.py:

"""Python preset using pylsp instead of ty."""

def servers():
    return [
        ['pylsp'],
        ['ruff', 'server']
    ]

Configuring individual servers

The initializationOptions field of the LSP initialize request can carry a special rass sub-object that lets you target configuration at individual multiplexed servers without the server seeing it.

"initializationOptions": {
  "settings": {
    "someCommonOption": true
  },
  "rass": {
    "ruff": {
      "settings": {
        "fixAll": true,
        "organizeImports": true
      }
    }
  }
}

Rass strips the rass key before forwarding. The remaining keys in rass are Python regexes matched against each server's executable name; a match merges that value on top of the generic options. So above, ty gets {"settings": {"someCommonOption": true}} and ruff gets {"settings": {"someCommonOption": true, "fixAll": true, "organizeImports": true}}.

The same mechanism applies to client responses to workspace/configuration server requests: each item in the response array is inspected for a rass key and filtered the same way before being forwarded to the requesting server. To achieve the same per-server split as above via workspace/configuration, a client would respond with:

[
  {
    "settings": {
      "someCommonOption": true
    },
    "rass": {
      "ruff": {
        "settings": {
          "fixAll": true,
          "organizeImports": true
        }
      }
    }
  }
]

ty receives {"settings": {"someCommonOption": true}} and ruff receives {"settings": {"someCommonOption": true, "fixAll": true, "organizeImports": true}}.

In both cases the regex is matched against the server's name. For initializationOptions this is necessarily the executable name, since the server hasn't had a chance to report its own name yet. For workspace/configuration it is the name the server reported in its initialize response. For well-behaved servers like ty and ruff these are the same, so a regex like ruff works identically in both contexts.

Performance

Performance is always a question, and it's early days. But some of the optimizations that rass makes, like caching the data cookies of code actions, completions and diagnostics and not sending them to the client may make a non-negligible difference in your client's performance. The ruff server in sometimes sends more than half its weight of diagnostics lists in data cookies. Other more aggressive optimizations are possible in the future, like capping diagnostics and completions.

Python seems to be "fast enough". Early measurements show rass to spend 8x as much time waiting for input/output as running instructions. This makes sense as most of its work is redirecting messages around, doing the odd JSON sniffing/injection here and there.

See also the experimental streaming diagnostics extension section for another potential optimization opportunity for clients.

Architecture

The codebase lives in src/rassumfrassum/ and is split into several modules:

  • main.py is the main entry point with command-line processing and argument parsing. It calls run_multiplexer from rassum.py to start the multiplexer.

  • presets.py handles preset discovery and loading, searching user config directories (XDG-compliant) and bundled presets.

  • rassum.py contains run_multiplexer which starts a bunch of async tasks to read from the clients and servers, and waits for all of them. The local lexical state in run_multiplexer tracks JSONRPC requests, responses, and notifications, and crucially the progress of ongoing aggregation attempts. In as much as possible, rassum.py should be just a JSONRPC-aggregator and not know anything about particular custom handling of LSP message types. There are a few violations of this principle, but whenever it needs to know what to do, it asks/informs the upper layer in frassum.py about in-transit messages.

  • frassum.py contains the business logic used by rassum.py facilities. This one fully knows about LSP. So it knows, for example, how to merge initialize and shutdown responses, when to reject a stale textDocument/publishDiagnostics and how to do the actual work for aggregation.

  • util.py provides logging utilities and general-purpose helpers like dict merging for debugging and monitoring the multiplexer's operation.

  • test.py contains test utilities used by both client and server test scripts.

  • json.py handles bare JSON-over-stdio logistics and is completely ignorant of LSP. It deals with protocol framing and I/O operations.

Testing

There are tests under test/. Each test is a subdir, usually with a client.py, a server.py (of which instances are spawned to emulate multiple servers) and a run.sh, which creates a FIFO special file to wire up the stdio connections and launches client.py connected to rass. client.py has the test assertions. Both client.py and server.py use common utils from src/rassumfrassum/test.py.

To run all tests, use test/run-all.sh.

Logging

The stderr output of rass is useful for peeking into the conversation between all entities and understanding how the multiplexer operates.

Options to rass

Use --help to see all options.

The --delay-ms N option delays all JSONRPC messages sent to the client by N milliseconds. Each message gets its own independent timer, so if two messages arrive at t=0.5s and t=1.5s with a 3000ms delay, they'll be dispatched at t=3.5s and t=4.5s respectively. Useful for diagnostics and testing.

The --drop-tardy option controls an aspect of the "aggregation". If it's true and a server takes too long to respond to a request, or send a mergeworthy notification, any messages that arrive too late are simply dropped and the client sees whatever it got when the timeout expired. If it's false, the most up-to-date state of the aggregation is simply retransmitted to the client. The default is false.

The --logic-class CLASS option specifies which routing logic class to use. The default is LspLogic. You can specify a simple class name (which will be looked up in the rassumfrassum.frassum module) or a fully qualified class name like mymodule.MyCustomLogic. This is useful for extending rass with custom routing behavior by subclassing LspLogic.

The --stream-diagnostics and --no-stream-diagnostics options control whether diagnostics are streamed incrementally or aggregated before sending. When streaming is enabled (the default), clients receive $/streamDiagnostics notifications as each server responds. When disabled, diagnostics are aggregated and sent as standard textDocument/publishDiagnostics notifications. See the Streaming Diagnostics Protocol Extension section for details.

FAQ

(...not really, noone's really asked anything yet...)

Related projects?

There's lspx! Never tried it, but some people are using it. Development started in this Eglot discussion thread: https://github.com/joaotavora/eglot/discussions/1429

There's also this defunct lsplex thing by myself in C++ that went nowhere.

Project name?

I'm tired of fretting about names. Kudos if you can guess where I stole this one from. Used to be called dada, btw.

Bugs?

Probably a million. The LSP flora is hard enough to navigate, and maintaining the Eglot client is hard enough because of that. So this is fun and potentially useful but adds another failure point. A pretty big one at that, since of the hundreds (thousands?) of LSP servers out there, there are uncountable combinations of them, and some will definitely trip you up.

Issue reports?

Read the preceding section. If you use this and want to report something, you can start discussions or create issues at will. If you create an issue, I might just close it with a cantmakesenseofthis label which just means I can't make sense of it just yet. Also I have very little time for OSS these days, so this is a totally NO WARRANTY, YMMV thing. If I close your issue just like that, doesn't mean you're a bad person, so don't fret. If you can provide an easy, simple, 100% idiot-proof recipe demonstrating the bug the chances that I'll address it are slightly higher. Else, just fork this repo, this is just Python and you're probably a programmer right?

Did I vibe code this junk?

Yeah, a bit, with some heavy coaching, then I took over. The boring bits are definitely an LLM's.

Future/roadmap?

I might rewrite this in Rust or C++ if it makes sense. Having an LSP middleware opens up some possibilities for making JSON communication more efficient.

Streaming diagnostics

Rassumfrassum implements an optional experimental non-standard protocol extension for streaming diagnostics from multiple sources. Rather than having clients and users wait for aggregations, this allows receiving diagnostics incrementally as different sources of diagnostics potentially respond out-of-phase. Although the protocol is designed to serve Rass's use case (where sources == multiplexed servers) it could theoretically be reused by any server that wants to provide different types of diagnostics (warnings, errors, linter results) separately.

Protocol flow

Negotiation happens when the client advertises support by sending $streamingDiagnostics capability in the initialize request. Rassumfrassum responds with $streamingDiagnosticsProvider set to true in its capabilities.

Now, consider a simple example with two servers and one file. When the client sends textDocument/didOpen for file.py at version 0, rassumfrassum forwards the notification to both servers.

Let's assume the first server quickly sends a textDocument/publishDiagnostics notification which rassumfrassum converts to $/streamDiagnostics and forwards to the client. This notification includes the uri of the file, the diagnostics array, the document version (0), and a bonus token identifying the source server. The client stores these diagnostics indexed by the triplet (version, uri, token). Let's also assume the second server doesn't support textDocument/publishDiagnostics but rather textDocument/diagnostic "pull" requests. Rassumfrassum sends an internal pull to it and the response is also converted to a $/streamDiagnostics notification, with a different token but the same uri and version. The client stores this second batch separately and updates its display by combining diagnostics from both tokens.

Now the user edits the file. The client sends textDocument/didChange with version 1. Both servers analyze the new content and the process repeats. When each $/streamDiagnostics notification arrives, the client replaces the old diagnostics for that specific (version, uri, token) triplet. The diagnostics from the first server's version 0 are replaced by its version 1 diagnostics. Same for the second server.

The kind field may be present with value "unchanged" to indicate the diagnostics for this token haven't changed. In this case the client reuses any previous diagnostics for that uri and token.

A complete reference implementation can be found in eglot.el in the eglot-handle-notification method for $/streamDiagnostics.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

rassumfrassum-0.3.4.tar.gz (52.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

rassumfrassum-0.3.4-py3-none-any.whl (50.3 kB view details)

Uploaded Python 3

File details

Details for the file rassumfrassum-0.3.4.tar.gz.

File metadata

  • Download URL: rassumfrassum-0.3.4.tar.gz
  • Upload date:
  • Size: 52.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for rassumfrassum-0.3.4.tar.gz
Algorithm Hash digest
SHA256 3fb7368f73dac7ef2b4accb0092738eab418fa3d21234160e638339550f8d582
MD5 871fd8838805eec1db9e49d9e7630416
BLAKE2b-256 9318bfdbf1fb2548f9a3121ae64574355de2cf7b83107643b15739f80988db1d

See more details on using hashes here.

Provenance

The following attestation bundles were made for rassumfrassum-0.3.4.tar.gz:

Publisher: publish.yml on joaotavora/rassumfrassum

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file rassumfrassum-0.3.4-py3-none-any.whl.

File metadata

  • Download URL: rassumfrassum-0.3.4-py3-none-any.whl
  • Upload date:
  • Size: 50.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for rassumfrassum-0.3.4-py3-none-any.whl
Algorithm Hash digest
SHA256 82c72d7d2e49a6d0fa3fe664af6e33af7297b550d548de333cb2263a062a5e3b
MD5 c4dd87d01e740910c018184464516e35
BLAKE2b-256 f7ceb64787031e65d512a1453f0bfbac3d537e7f9d8b0a09c9c6796ebeb7c2b5

See more details on using hashes here.

Provenance

The following attestation bundles were made for rassumfrassum-0.3.4-py3-none-any.whl:

Publisher: publish.yml on joaotavora/rassumfrassum

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page