docs/internal/multi-lsp-design.md
This document explores supporting an arbitrary number of LSP servers per buffer/language in Fresh, drawing on research of how other editors approach this problem, and proposing design alternatives for Fresh's implementation.
Fresh currently supports one LSP server per language. The LspManager stores handles in a
HashMap<String, LspHandle> keyed by language name. LSP dispatch is correctly tied to the
buffer's language: with_lsp_for_buffer() reads state.language from the buffer and uses it
to find the corresponding handle, so requests always go to the right server for the file type.
However, the one-handle-per-language constraint means:
pyright (type checking) and ruff (linting/formatting) for Python simultaneously.typescript-language-server alongside eslint-lsp for TypeScript.tailwindcss-language-server) augment a
primary server for HTML/JSX files.Many real-world workflows require multiple complementary servers per language:
| Primary Server | Add-on Server | Why |
|---|---|---|
rust-analyzer | bacon-ls (diagnostics from cargo check) | Faster incremental diagnostics |
pyright | ruff | Type checking + linting/formatting |
typescript-language-server | eslint-lsp | Language features + linting |
vscode-html-language-server | tailwindcss-language-server | HTML + CSS utility classes |
gopls | golangci-lint-langserver | Go features + extended linting |
Approach: Native multi-client per buffer
Neovim's built-in LSP client natively supports attaching multiple LSP clients to a single buffer. Each client is independent and managed separately.
Request dispatch:
vim.lsp.buf.* methods (hover, definition, references, etc.) are sent to all attached clients.vim.lsp.buf_request_all collects responses
from all clients and merges them.nvim-cmp) which aggregate
items from all attached clients.Conflict resolution:
root_markers + workspace_required (0.11.1+) control which servers activate per project.root_dir functions allow dynamic per-buffer activation decisions.Configuration: Per-server configs in nvim-lspconfig, per-buffer attachment via autocommands.
Pain points:
ts_ls + denols) require manual activation guards.Approach: Extension-mediated multi-server
VS Code does not directly expose multi-LSP configuration to users. Instead, each extension can register its own language server(s). Multiple extensions targeting the same language result in multiple servers running simultaneously.
Request dispatch:
Conflict resolution:
activationEvents).Pain points:
Approach: Primary server + add-on servers
lsp-mode has native multi-server support via the :add-on? flag.
Request dispatch:
:add-on?, only the highest-priority server starts for a given mode.:add-on? t start in parallel alongside the primary server.Configuration:
:priority controls which non-add-on server wins.lsp-enabled-clients / lsp-disabled-clients per project (via .dir-locals.el).Pain points:
Approach: External multiplexer
Eglot (Emacs's built-in LSP client) takes a fundamentally different approach: it delegates
multi-server coordination to an external multiplexer called rassumfrassum (rass).
How it works:
rass is a standalone process that presents itself as a single LSP server to Eglot.Usage: C-u M-x eglot RET rass -- server1 --stdio -- server2 --stdio RET
Advantages:
rass works with any LSP client (Neovim, Helix, etc.).Disadvantages:
Approach: Declarative per-feature routing (best-in-class)
Helix has the most sophisticated and ergonomic multi-LSP support among terminal editors.
Configuration:
[language-server.typescript-language-server]
command = "typescript-language-server"
args = ["--stdio"]
[language-server.eslint-lsp]
command = "vscode-eslint-language-server"
args = ["--stdio"]
[[language]]
name = "typescript"
language-servers = [
{ name = "eslint-lsp", only-features = ["diagnostics", "code-action"] },
"typescript-language-server"
]
Request dispatch:
language-servers array that supports a
feature handles it.diagnostics, code-action, completion, document-symbols, and
workspace-symbols are collected from all servers and merged.hover, goto-definition, format, rename, signature-help,
etc. use only the first capable server.Per-feature routing:
only-features = [...] — server only provides listed capabilities.except-features = [...] — server provides everything except listed capabilities.Advantages:
Pain points:
Approach: Extension-based multi-server with per-server diagnostics
Zed supports multiple language servers per language through its extension system.
Request dispatch:
extension.toml.Vec<(LanguageServerId, Vec<DiagnosticEntry>)>.merge_lsp_diagnostics merges new diagnostics without clobbering other servers' entries.Conflict resolution:
settings.json.Limitations:
Approach: Multiple clients with disabled_capabilities
Sublime Text's LSP package natively supports multiple active servers per file type.
Conflict resolution:
disabled_capabilities setting on individual server configs to suppress specific features.LSP-pyright for completions/navigation and LSP-ruff for linting, with
formatting disabled on one of them.Configuration: Per-server settings in LSP.sublime-settings with per-project overrides.
Scope: Each server instance is bound to a single Sublime Text window.
| Editor | Multi-server | Routing strategy | Merged features | Per-feature control | Configuration |
|---|---|---|---|---|---|
| Neovim | Native | All clients get all requests | Completions (via plugins), references | No (workarounds only) | Lua/autocommands |
| VS Code | Via extensions | Extension-mediated | Completions, diagnostics, code actions | No (extension-controlled) | Extensions + settings |
| lsp-mode | Native | Primary + add-ons | Diagnostics, completions | Priority only | Elisp + dir-locals |
| Eglot | External mux | Multiplexer-mediated | Depends on mux | Depends on mux | External tool config |
| Helix | Native | Priority-ordered + merged | diagnostics, completion, code-action, symbols | only-features / except-features | TOML declarative |
| Zed | Via extensions | Per-extension | Diagnostics | Settings-based | JSON + extension.toml |
| Sublime | Native | All active | All features | disabled_capabilities | JSON settings |
Based on the research above and Fresh's existing architecture, we propose the following principles and goals for multi-LSP support:
Predictability over magic. Users should be able to understand which server handles which feature by reading their configuration. No hidden heuristics or race conditions.
Declarative configuration. Feature routing should be configured statically, not
determined at runtime by arrival order of responses. Helix's only-features /
except-features model is the gold standard here.
Sensible defaults with escape hatches. Common multi-server setups (e.g., primary + linter) should work out of the box with minimal configuration. Power users should have full control.
Merged where natural, exclusive where necessary. Features that are naturally aggregatable (diagnostics, completions, code actions, symbols) should merge results from all servers. Features that produce a single result (hover, definition, formatting, rename) should use the highest-priority server.
Backward compatibility. Existing single-server configurations must continue to work unchanged. Multi-server support is additive.
Resource awareness. Running multiple servers has real cost (memory, CPU, startup time).
The system should make it easy to keep servers lean (e.g., only-features to avoid
unnecessary work).
Observability. Users should be able to see which servers are running, which features each provides, and which server responded to a given request. This is critical for debugging.
G1: Multiple servers per language. Support configuring and running N servers for a single language, each with its own command, args, and initialization options.
G2: Per-feature routing. Allow users to declaratively control which server handles which LSP features (completions, diagnostics, formatting, hover, etc.).
G3: Response merging. For mergeable features (diagnostics, completions, code actions, symbols), collect and merge responses from all eligible servers.
G4: Priority ordering. For exclusive features, use a configurable priority order to determine which server handles the request.
G5: Independent lifecycle. Each server should start, stop, crash-recover, and restart independently. A crash in the linting server should not affect the primary server.
G6: Shared document sync. All servers attached to a buffer must receive didOpen,
didChange, didClose, and didSave notifications for that buffer.
G7: Per-server capability tracking. Track and expose each server's actual capabilities
(from InitializeResult) separately, and intersect with the user's feature routing config.
G8: Status/observability UI. Show per-server status (running, error, capabilities) in the status bar or a dedicated panel.
Flow 1: Basic multi-server setup (primary + linter)
config.json:
{
"lsp": {
"typescript": [
{ "command": "typescript-language-server", "args": ["--stdio"] },
{ "command": "vscode-eslint-language-server", "args": ["--stdio"],
"only_features": ["diagnostics", "code_action"] }
]
}
}
.ts file.typescript-language-server handles completions, hover, definition, etc.
eslint-lsp provides additional diagnostics and code actions.Flow 2: Formatter override
prettier for formatting TypeScript instead of tsserver's formatter:
{
"lsp": {
"typescript": [
{ "command": "typescript-language-server", "args": ["--stdio"],
"except_features": ["format"] },
{ "command": "prettier-lsp", "args": ["--stdio"],
"only_features": ["format"] }
]
}
}
prettier-lsp is invoked.Flow 3: Observability
Ctrl+P > >) and selects "Show LSP Status".typescript (2 servers):
typescript-language-server [running] — completions, hover, definition, references, rename
eslint-lsp [running] — diagnostics, code_action
Flow 4: Server crash isolation
eslint-lsp crashes.typescript-language-server continues working unaffected.eslint-lsp in error state.eslint-lsp only.didOpen is re-sent to eslint-lsp for all open TS buffers.Flow 5: Per-project override
.fresh/config.json overrides the global config to add a project-specific server
or disable one of the global servers for this project.Fresh uses JSON configuration with a 4-level layered resolution (highest to lowest priority):
.fresh/session.json (temporary per-session overrides).fresh/config.json (per-project)~/.config/fresh/config_linux.json etc. (OS-specific)~/.config/fresh/config.json (global defaults)LSP servers are configured in the "lsp" key as a HashMap<String, LspServerConfig> where
keys are language names. The LspServerConfig struct (in types.rs) has:
command, args, enabled, auto_startprocess_limits (memory/CPU per-server)initialization_options (passed to LSP Initialize)env (environment variables for the server process)language_id_overrides (extension → LSP languageId mapping)The PartialConfig system enables layered merging: project configs fill in only the fields
they override; missing fields fall through to user/default config via merge_with_defaults().
UI surface for LSP commands:
Ctrl+P then >): "Show LSP Status", "Start/Restart LSP Server",
"Stop LSP Server", "Toggle LSP for Current Buffer", "Rust LSP: Configure Mode"The LspManager stores handles: HashMap<String, LspHandle> (one handle per language) and
config: HashMap<String, LspServerConfig> (one config per language). This is the primary
structure that changes to support multiple servers.
Extend the existing "lsp" config key so that a language value can be either a single
LspServerConfig object (backward compatible) or an array of named server configs:
{
"lsp": {
"rust": { "command": "rust-analyzer", "auto_start": true },
"typescript": [
{ "name": "tsserver", "command": "typescript-language-server", "args": ["--stdio"],
"auto_start": true, "except_features": ["format"] },
{ "name": "eslint", "command": "vscode-eslint-language-server", "args": ["--stdio"],
"auto_start": true, "only_features": ["diagnostics", "code_action"] },
{ "name": "prettier", "command": "prettier-lsp", "args": ["--stdio"],
"only_features": ["format"] }
]
}
}
| Aspect | Assessment |
|---|---|
| Backward compat | Single-object form still works; serde #[serde(untagged)] enum |
| Feature routing | only_features / except_features per server |
| Priority | Array order = priority (first capable server wins for exclusive features) |
| Server identity | name field for display/status; defaults to command basename |
| Complexity | Moderate — config schema becomes a union type |
Servers defined globally, languages reference them by name:
{
"lsp_servers": {
"tsserver": { "command": "typescript-language-server", "args": ["--stdio"] },
"eslint": { "command": "vscode-eslint-language-server", "args": ["--stdio"] }
},
"lsp": {
"typescript": {
"servers": [
"tsserver",
{ "name": "eslint", "only_features": ["diagnostics", "code_action"] }
]
}
}
}
| Aspect | Assessment |
|---|---|
| Backward compat | Breaking change — requires migration |
| Reuse | Servers shared across languages (e.g., prettier for JS + TS + CSS) |
| Complexity | Higher — two config sections to coordinate |
| Readability | Better for large configs with many languages sharing servers |
Keep current single-server config as "primary" and add an addons array:
{
"lsp": {
"typescript": {
"command": "typescript-language-server", "args": ["--stdio"],
"addons": [
{ "command": "vscode-eslint-language-server", "args": ["--stdio"],
"only_features": ["diagnostics", "code_action"] }
]
}
}
}
| Aspect | Assessment |
|---|---|
| Backward compat | Fully compatible — addons is a new optional field |
| Conceptual model | Clear primary/secondary hierarchy |
| Limitation | No way for an add-on to override primary for a feature (except_features only on primary) |
| Complexity | Low — minimal schema change |
Change handles: HashMap<String, LspHandle> to handles: HashMap<String, Vec<LspHandle>>
where the key remains the language name and the vec is ordered by priority.
Each LspHandle gains:
name: String (e.g., "tsserver", "eslint")feature_filter: FeatureFilter (only/except features)InitializeResult intersected with the feature filterDispatch logic:
fn handles_for_feature(&self, language: &str, feature: LspFeature) -> Vec<&LspHandle>
| Aspect | Assessment |
|---|---|
| Invasiveness | Moderate — touches LspManager, dispatch, config loading |
| Parallelism | Merged features send requests to all handles concurrently |
| Lifecycle | Each handle independent — crash/restart isolation built-in |
| Document sync | didOpen/didChange sent to all handles for the language |
Change to handles: HashMap<(String, String), LspHandle> keyed by (language, server_name).
| Aspect | Assessment |
|---|---|
| Lookup | O(n) scan needed to find all servers for a language |
| Simplicity | Minimal change to handle storage |
| Iteration | Awkward iteration patterns for per-language operations |
Instead of changing internal architecture, support configuring an external multiplexer as the "server" for a language:
{
"lsp": {
"python": {
"command": "rass",
"args": ["--", "pyright", "--", "ruff", "server"]
}
}
}
| Aspect | Assessment |
|---|---|
| Invasiveness | Zero — no internal changes |
| Flexibility | Delegates all complexity to the multiplexer |
| Dependency | Requires external tool installation |
| Observability | Opaque — Fresh can't see individual servers |
| User experience | Poor — debugging through two layers of abstraction |
Based on research across all editors, features naturally divide into two categories:
Merged features (results from all servers concatenated/unioned):
textDocument/publishDiagnostics and pull diagnosticstextDocument/completiontextDocument/codeActiontextDocument/documentSymbolworkspace/symbolExclusive features (first-priority server wins):
textDocument/hovertextDocument/definition (and declaration, typeDefinition, implementation)textDocument/referencestextDocument/formatting and textDocument/rangeFormattingtextDocument/rename (and prepareRename)textDocument/signatureHelptextDocument/inlayHinttextDocument/foldingRangetextDocument/semanticTokens/*textDocument/documentHighlightDiagnostics:
publishDiagnostics from server A never clears server B's diagnostics.Completions:
Code Actions:
For exclusive features, the dispatch helper becomes:
fn with_lsp_for_buffer(
&mut self,
buffer_id: BufferId,
feature: LspFeature,
f: impl FnOnce(&LspHandle, &Uri, &str) -> R,
) -> Option<R>
This iterates the priority-ordered handles for the buffer's language, finds the first that
(a) has the feature in its filter, (b) has the capability from the server, and (c) is in
Running state.
For exclusive features, if the primary server returns null/empty, there is a design choice:
Recommendation: start with no fallback (simpler), add opt-in fallback later.
All servers for a language must receive document lifecycle notifications:
textDocument/didOpen: sent to all servers when a buffer is opened or a new server starts.textDocument/didChange: sent to all servers on every edit.textDocument/didClose: sent to all servers when a buffer is closed.textDocument/didSave: sent to all servers when a buffer is saved.Key change: BufferMetadata.lsp_opened_with: HashSet<u64> already tracks which server
instance IDs have received didOpen. This naturally extends to multiple servers — each
server has its own handle ID, and didOpen is sent independently per handle.
Document version: Each server independently tracks document versions. Since Fresh sends the same edits to all servers in the same order, versions stay synchronized.
Risk: If one server is slow to process didChange, it may have a stale view when
receiving a request. This is inherent to the LSP protocol and is the server's responsibility
to handle (the protocol includes version numbers for this reason).
workspace/applyEdit HandlingWhen a server sends workspace/applyEdit (e.g., from a code action or rename):
applied: false.didChange is sent to all
servers (including server A, per protocol spec).Each server handle manages its own:
This is already the architecture of LspHandle/LspTask — the change is simply having
multiple handles per language instead of one.
process_limits already exist per-server.didChange to N servers means N servers parsing on every keystroke.
Mitigation: servers that only need diagnostics can use TextDocumentSyncKind::Full with
debounced saves rather than incremental sync.auto_start
controls which servers start eagerly vs. lazily.Today update_lsp_status_from_server_statuses() shows all running servers across all
languages (e.g., LSP [python: ready, rust: ready, typescript: ready]). This is already
noisy and would be worse with multi-server (e.g., 3 languages × 2 servers = 6 entries).
Recommendation (independent of multi-LSP): Filter the status bar to show only the
server(s) relevant to the active buffer's language. The active buffer's language is already
available via self.buffers.get(&self.active_buffer()).map(|s| &s.language). This makes
the status bar contextual and directly actionable — what you see is what affects the file
you're editing.
With multi-LSP this becomes: LSP [pyright: ready, ruff: ready] when editing a Python file,
rather than listing every server across every language.
Extend the existing LSP status display (accessible via command palette or status bar click) to show per-server information:
Language: typescript (2 servers)
┌─ tsserver [Running]
│ Command: typescript-language-server --stdio
│ Features: completion, hover, definition, references, rename, signature-help, inlay-hints
│ PID: 12345, Memory: 120MB
│
└─ eslint [Running]
Command: vscode-eslint-language-server --stdio
Features: diagnostics, code-action (only_features filter)
PID: 12346, Memory: 45MB
Each diagnostic in the diagnostics panel could optionally show its source server:
error[tsserver]: Type 'string' is not assignable to type 'number' src/foo.ts:10:5
warning[eslint]: Unexpected console statement (no-console) src/foo.ts:15:3
LspManager currently gets its root_uri from cwd at startup. When a user runs
fresh ~/.config/wezterm/wezterm.lua from $HOME, the workspace root becomes $HOME,
and servers like LuaLS correctly refuse to scan it. This is the only editor that uses
cwd-based root detection — every other editor walks upward from the file looking for
language-specific root markers.
Add a root_markers field to LspServerConfig:
pub struct LspServerConfig {
// ... existing fields ...
/// File/directory names to search for when detecting the project root.
/// The editor walks upward from the opened file's directory looking for
/// any of these markers. The first directory containing a match becomes
/// the workspace root sent to the LSP server.
///
/// If empty, falls back to the file's parent directory.
/// If the walk reaches a filesystem boundary without a match, uses the
/// file's parent directory (never cwd or $HOME).
#[serde(default)]
pub root_markers: Vec<String>,
}
detect_workspace_root(file_path, root_markers) -> PathBuf:
dir = file_path.parent()
while dir is not None:
for marker in root_markers:
if dir.join(marker).exists():
return dir
dir = dir.parent()
return file_path.parent() // fallback: file's directory
per_language_root_uris has an entry (plugin-set, e.g. C# plugin) → use itconfig.root_markers is non-empty → walk upward from file_pathfile_path using generic markers [".git"]This requires force_spawn (or its caller) to know which file triggered the spawn.
Currently force_spawn takes only language: &str — add an optional file_path parameter.
{
"lua": { "root_markers": [".luarc.json", ".luarc.jsonc", ".luacheckrc", ".stylua.toml", ".git"] },
"rust": { "root_markers": ["Cargo.toml", "rust-project.json", ".git"] },
"python": { "root_markers": ["pyproject.toml", "setup.py", "setup.cfg", "pyrightconfig.json", ".git"] },
"javascript": { "root_markers": ["tsconfig.json", "jsconfig.json", "package.json", ".git"] },
"typescript": { "root_markers": ["tsconfig.json", "jsconfig.json", "package.json", ".git"] },
"go": { "root_markers": ["go.mod", "go.work", ".git"] },
"c": { "root_markers": ["compile_commands.json", "CMakeLists.txt", "Makefile", ".git"] },
"cpp": { "root_markers": ["compile_commands.json", "CMakeLists.txt", "Makefile", ".git"] }
}
Languages without explicit root_markers get [".git"] as a universal fallback, with the
file's parent directory as the final fallback (matching Helix/Neovim behavior).
| File | Change |
|---|---|
types.rs | Add root_markers: Vec<String> to LspServerConfig, update merge_with_defaults |
config.rs | Add default root_markers for each language in populate_lsp_config |
services/lsp/manager.rs | Add detect_root_from_file(file_path, markers) -> PathBuf. Change force_spawn to accept optional file path, use new root detection instead of get_effective_root_uri |
app/file_operations.rs | Pass the file path through when calling try_spawn / force_spawn |
per_language_root_uris (plugin-set roots) still take priority — no breakage for C# plugin etc.root_uri from cwd becomes the last resort fallback (after markers and file-parent),
or could be removed entirely since it's never the right answer when markers exist.root_markers: [] in config → file's parent directory (still better than cwd).{
"lsp": {
"lua": {
"command": "lua-language-server",
"root_markers": [".luarc.json", ".git"]
}
}
}
Or "root_markers": [] to force file-directory-only behavior (no upward walk).
Since root_markers is a field on LspServerConfig, it naturally becomes per-server in the
array config form. Different servers for the same language can have different workspace roots:
{
"lsp": {
"typescript": [
{ "name": "tsserver", "command": "typescript-language-server", "args": ["--stdio"],
"root_markers": ["tsconfig.json", "package.json", ".git"] },
{ "name": "tailwind", "command": "tailwindcss-language-server", "args": ["--stdio"],
"only_features": ["completions"],
"root_markers": ["tailwind.config.js", "tailwind.config.ts", ".git"] }
]
}
}
Each server's root is resolved independently using its own markers. This matters for monorepo
setups where a linter might need the monorepo root (where the config lives) while the type
checker needs the package root (where tsconfig.json lives).
Based on the research and analysis above, we recommend:
| Decision | Choice | Rationale |
|---|---|---|
| Config model | Option A: Array-of-objects | Best balance of backward compat, simplicity, and expressiveness |
| Internal arch | Option I: Multi-handle Vec | Natural extension of existing architecture, clean dispatch |
| Feature routing | only_features / except_features | Proven in Helix, declarative, predictable |
| Merged features | diagnostics, completion, code_action, document_symbols, workspace_symbols | Consensus across all editors studied |
| Exclusive dispatch | Priority-ordered, no fallback (initially) | Simpler, predictable; fallback can be added later |
| Diagnostics | Per-server tracking with merged display | Prevents clobbering, enables attribution |
| Observability | Enhanced "Show LSP Status" + diagnostic attribution | Essential for debugging multi-server setups |
| Workspace root | Per-language root_markers with upward walk | Fixes cwd-based root; matches every other editor |
Phase 0: Per-language workspace root detection (independent of multi-LSP)
root_markers: Vec<String> to LspServerConfig in types.rs.merge_with_defaults to merge root_markers (non-empty overrides default).root_markers per language in populate_lsp_config (see section 4.8).detect_root_from_file(file_path, markers) -> PathBuf in manager.rs.force_spawn to accept optional file path for root detection.file_operations.rs to pass file path through.[".git"] walk > file's parent dir.Phase 1: Core multi-handle infrastructure
LspServerConfig with name, only_features, except_features fields.LspFeature enum listing all routable features.FeatureFilter type implementing the only/except logic.LspServerConfig | Vec<LspServerConfig>
(via #[serde(untagged)] enum in PartialConfig; update merge_hashmap_recursive in
partial_config.rs to handle vec-valued entries).LspManager.handles to HashMap<String, Vec<LspHandle>>.LspManager.config to HashMap<String, Vec<LspServerConfig>>.try_spawn / force_spawn to manage multiple handles per language.didOpen/didChange/didClose/didSave to broadcast to all handles.Phase 2: Dispatch routing
handles_for_feature(language, feature) → Vec<&LspHandle> / Option<&LspHandle>.with_lsp_for_buffer into with_lsp_for_buffer (exclusive) and
with_all_lsp_for_buffer_feature (merged).lsp_requests.rs to use new dispatch helpers.Phase 3: Diagnostics per-server tracking
(server_name, diagnostics) pairs.publishDiagnostics handler to replace only the originating server's diagnostics.diagnostics.rs overlay application to merge all servers' diagnostics.Phase 4: Completion merging
Phase 5: Observability
Config deserialization:
only_features and except_features are mutually exclusive (validation error if both).name defaults to command basename.Feature filter:
FeatureFilter::All allows all features.FeatureFilter::Only(set) allows only listed features.FeatureFilter::Except(set) allows all except listed features.Dispatch routing:
handles_for_feature returns correct handles for merged features (all eligible).handles_for_feature returns first eligible handle for exclusive features.Diagnostics merging:
Document sync:
didOpen sent to all handles when buffer opens.didOpen re-sent to a restarted handle (new handle ID).didChange sent to all handles on edit.didClose sent to all handles when buffer closes.didSave sent to all handles when buffer saves.Workspace root detection:
detect_root_from_file finds marker in parent dir → returns parent dir.detect_root_from_file finds marker two levels up → returns grandparent dir.detect_root_from_file with no marker found → returns file's parent dir.detect_root_from_file with empty markers list → returns file's parent dir.per_language_root_uris takes priority over marker walk.$HOME or filesystem root as workspace root.Two-server lifecycle:
Merged diagnostics E2E:
[d1, d2].[d3, d4].[d1'].[d1', d3, d4] (server A's updated, server B's unchanged).Exclusive feature routing E2E:
except_features: ["format"].only_features: ["format"].Completion merging E2E:
[c1, c2].[c3, c4].Backward compatibility E2E:
name, feature filters) works.Workspace root detection E2E:
Cargo.toml two levels up — verify LSP receives the
Cargo.toml directory as rootUri in Initialize.$HOME with no markers — verify LSP receives the file's parent dir,
not $HOME.root_markers — verify each server
receives a different rootUri.Latency impact:
Memory overhead:
process_limits are respected per-server.Edit throughput:
didChange broadcast overhead with 1 vs. 3 servers.Should references be a merged feature? Helix treats it as exclusive, but merging
references from multiple servers could be useful (e.g., one server finds TypeScript
references, another finds CSS class usage). Risk: duplicates and confusion.
Should we support per-buffer server selection? E.g., a .tsx file might want different
servers than a .ts file, even though both are "typescript". The existing
language_id_overrides partially addresses this.
How should workspace/applyEdit from add-on servers work? If an eslint code action
wants to apply a fix, it sends workspace/applyEdit. This should work fine as long as
edits are applied atomically and didChange is broadcast afterward.
Should we support the external multiplexer approach in addition to native multi-server?
Users could always configure rass as their server command today. No changes needed, but
we could document it as an alternative.
How should "Start/Restart LSP Server" work with multiple servers? Options: restart all servers for the language, present a picker listing individual servers by name, or add separate "Restart All LSP Servers" command. The existing "Stop LSP Server" already shows a selection list, so extending this pattern to restart is natural.