Metadata-Version: 2.4
Name: pylspmux
Version: 0.0.1
Summary: LSP multiplexer letting several language servers share one file extension
Project-URL: Homepage, https://github.com/MyrikLD/pylspmux
Project-URL: Repository, https://github.com/MyrikLD/pylspmux
Project-URL: Issues, https://github.com/MyrikLD/pylspmux/issues
Author-email: Yorsh Siarhei <yorsh.srg@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: language-server,lsp,multiplexer,pyright,ruff
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Text Editors :: Integrated Development Environments (IDE)
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# pylspmux

An LSP multiplexer: presents several language servers to one client as if they
were a single server.

It exists because Claude Code maps a file extension to exactly one language
server. Registration keeps every matching server in an array, but routing
always takes index 0 and warns about the rest:

```
LSP: extension .py already handled by "pyright"; "ruff" will not be used for .py files
```

Registering `pylspmux` for `.py` instead lets pyright, ruff and pyrefly all
see the same files, with their diagnostics merged into one stream.

Nothing about the design is Python-specific — the same config runs
typescript-language-server alongside eslint for `.ts`.

## Install

```sh
pip install pylspmux
```

No runtime dependencies. The client spawns this process at every session
start, and each dependency is another way for it to fail to start; the whole
thing runs on a bare `/usr/bin/python3`.

## Use it

Once installed, `pylspmux` is just a command on `PATH`. Point your client's
LSP config at it instead of at the real server, and give it a `servers.json`
(see [Configuration](#configuration)) telling it which real servers to run.

For Claude Code, drop both in the project root — it reads a `.lsp.json` there
directly, no plugin needed:

`.lsp.json`:

```json
{
  "pylspmux": {
    "command": "pylspmux",
    "args": ["servers.json"],
    "extensionToLanguage": {
      ".py": "python",
      ".pyi": "python"
    }
  }
}
```

`servers.json`:

```json
{
  "servers": [
    {
      "name": "pyright",
      "command": "pyright-langserver",
      "args": [
        "--stdio"
      ]
    },
    {
      "name": "ruff",
      "command": "ruff",
      "args": [
        "server"
      ]
    },
    {
      "name": "pyrefly",
      "command": "pyrefly",
      "args": [
        "lsp"
      ],
      "suppressCodes": [
        "unused-import",
        "unused-variable"
      ]
    }
  ]
}
```

Restart the client, then check that all three servers came up:

```sh
PYLSPMUX_LOG=/tmp/mux.log claude
grep 'live servers' /tmp/mux.log
```

For any other LSP client the mechanism is the same: `pylspmux` replaces the
single server as the spawned command, with the path to `servers.json` as its
first argument. The key names in the client's own config format will differ.

## Configuration

```json
{
  "servers": [
    {
      "name": "pyright",
      "command": "pyright-langserver",
      "args": ["--stdio"],
      "enabled": true,
      "settings": {
        "python": { "analysis": { "typeCheckingMode": "standard" } }
      },
      "suppressCodes": []
    }
  ]
}
```

| Field                    | Meaning                                                                                                                           |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `name`                   | identifies the server, and becomes the diagnostic `source` if it sets none                                                        |
| `command`, `args`, `env` | how to spawn it                                                                                                                   |
| `settings`               | answers the server's `workspace/configuration` requests, and is pushed via `workspace/didChangeConfiguration` after `initialized` |
| `initializationOptions`  | passed through in `initialize`                                                                                                    |
| `suppressCodes`          | diagnostic codes to drop from this server                                                                                         |
| `enabled`                | keep an entry without running it                                                                                                  |

An invalid entry fails the whole load rather than being skipped: a silently
dropped server looks exactly like a server that found no problems, which is
the worst possible failure mode for a diagnostics tool. A server that fails to
_spawn_ is different — that degrades gracefully, and the rest keep working.

`pylspmux` takes the path to this file as its one required argument.

## How it works

Notifications (`didOpen`, `didChange`, `didSave`, `didClose`) broadcast to
every child. Requests go only to children advertising the matching capability,
and the answers are merged:

| Request                                                        | Merge                                                                                                         |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `hover`                                                        | every server's answer, each labelled with its name                                                            |
| `definition`, `typeDefinition`, `implementation`, `references` | concatenated, de-duplicated by target                                                                         |
| `documentSymbol`, `workspace/symbol`                           | concatenated, de-duplicated by name/kind/range                                                                |
| `prepareCallHierarchy`                                         | concatenated; each item remembers its owner so `incomingCalls`/`outgoingCalls` route back to the right server |

Requests coming _from_ a child (`workspace/configuration`,
`client/registerCapability`, `window/workDoneProgress/create`) are answered
here rather than forwarded, so the client's connection looks like an ordinary
single language server. `workspace/applyEdit` is refused — the client edits
files itself.

A child that fails to spawn, or dies later, is dropped: its diagnostics are
cleared and the remaining servers carry on. A child that stops answering hits
a 20-second timeout and is left out of that one merge.

### Two client quirks worth knowing

Both were read out of the Claude Code 2.1.215 bundle, and both shape the
design:

- A `publishDiagnostics` payload whose `version` trails the document the
  client holds is **dropped**. A merged payload is only as fresh as its
  slowest contributor, so republished diagnostics carry **no** `version`
  field at all — that skips the staleness check entirely.
- A payload with an empty `diagnostics` array is **ignored**, so "all problems
  cleared" never propagates. That is the client's own behaviour and applies
  equally without the multiplexer.

## pyrefly needs per-project config

At its default `basic` preset pyrefly reports almost nothing — it missed a
plain `result: str = add(1, 2)`. Add a `pyrefly.toml` to each project:

```toml
project-includes = ["**/*.py"]
preset = "strict"
```

Valid presets: `off`, `basic`, `legacy`, `default`, `strict`, `all`. Without
one, pyrefly is dead weight in the union.

Because pyrefly and ruff both flag unused imports, the shipped config
suppresses pyrefly's `unused-import` and `unused-variable`.

## Tests

```sh
.venv/bin/pytest                    # unit + fake-server end-to-end
.venv/bin/pytest -m integration     # spawns the real pyright/ruff/pyrefly
```

`tests/fake_server.py` is a scriptable language server driven by environment
variables, so the full pipeline can be tested deterministically without
waiting on a real type checker.

## Debugging

```sh
PYLSPMUX_LOG=/tmp/mux.log claude
```

Logs each child's stderr, its advertised capabilities, timeouts, and any
unhandled message. Nothing may go to stdout — that is the LSP stream — and
stderr is swallowed by the client, hence the file.
