lsp-cli 0.1.6

Command-line tool for talking to Language Server Protocol (LSP) servers from the terminal.
# LSP protocol

## Capabilities

- Some servers send client requests such as `client/registerCapability` immediately after the
  `initialize` response and expect those requests to be answered before later client traffic.
  lsp-cli therefore drains and replies to requests already queued after `initialized` and before
  later outgoing operations. Requests that race with the next client request are answered while
  that request is outstanding, instead of assuming request-response traffic is strictly
  one-directional or waiting for a fixed post-initialization quiet period.


## Diagnostics

- Diagnostics are not uniformly query-shaped across servers.
  Some servers support client-initiated `textDocument/diagnostic`, while others only publish
  `textDocument/publishDiagnostics` asynchronously after `didOpen` or background analysis.
  A CLI diagnostics command therefore cannot rely on only one path if it wants broad coverage.
- `textDocument/publishDiagnostics` is latest-state data, not an append-only stream.
  Servers may replace older diagnostics for the same URI with a newer notification, including an
  empty list to clear prior errors. Keep only the latest notification per URI instead of treating
  each publish as an independent result item.
- A diagnostics command that only opens files and waits a short fixed delay is fragile.
  Some servers publish diagnostics only after preamble building, indexing, or other async work, so
  the client may need to wait through a bounded timeout budget instead of assuming diagnostics are
  available immediately after `didOpen`.
- Background-work progress such as `$/progress` is useful for diagnostics timing, but it is not a
  diagnostics result by itself. A server can report indexing activity without publishing any
  diagnostics yet, and some servers expose progress inconsistently, so progress should be treated as
  a hint rather than as the sole completion signal for `diag`.


# Daemon gotchas

- Daemon initialization compares workspace URIs literally. Directory URIs produced by
  `path_to_file_uri` end with `/`; raw socket clients and fixtures must use the same trailing
  slash in `rootUri` and workspace folder URIs, or initialization is rejected.

- LSP 3.17 assumes one server serves one tool. `lsp-cli daemon` therefore implements a
  conservative proxy policy instead of transparent multi-client sharing: only one client may be
  connected at a time, downstream `shutdown`/`exit` are handled locally, and a later client with
  different normalized `initialize` settings forces a fresh upstream server.
- `lsp-cli daemon` drops stale responses for disconnected clients and closes all client-owned
  documents on disconnect, but it does not persist dynamic registrations across sessions. If the
  upstream server uses `client/registerCapability` or `client/unregisterCapability`, lsp-cli marks
  the session as non-reusable and restarts the upstream server before the next client.
- Reused daemon sessions can attach after the upstream server has already finished indexing. To
  keep `wait-for-index` semantics stable for warm sessions, the daemon synthesizes a quiescent
  `experimental/serverStatus` notification after cached `initialize` replies when it already knows
  the upstream server is idle.
- Normal LSP commands opportunistically reuse the daemon socket. If the expected socket path exists
  but no daemon listens on it anymore, lsp-cli treats it as stale runtime state, removes the dead
  socket file, and falls back to starting a direct LSP server for that command.
- `stop` and `stop-all` use a private daemon control request over the Unix socket instead of normal
  LSP `shutdown`. This keeps regular client shutdown local to the proxy, but it also means `stop`
  only finds daemons whose socket path still matches the currently resolved workspace root and LSP
  command line; use `stop-all` when config changes make the exact match ambiguous.
- `workspace/applyEdit` only works while a client is actively connected. If no client is connected,
  lsp-cli rejects the request instead of editing files behind the client's back.
- During the LuaLS request-window benchmark, one `stop-all` cleanup reported an unexpected
  stop-response ID even though the daemon exited. Retrying against the same isolated runtime
  directory removed its stale socket. The unexpected response was not diagnosed; a failed stop
  response does not necessarily mean the daemon remains alive.
- Release profiling also reproduced a daemon exit after a successful query with
  `failed to write daemon client message: failed to write JSON-RPC message: Broken pipe`.
  Upstream notifications can race with client disconnect: the coordinator drains upstream
  traffic before client events, and a failed downstream write propagates out of `serve`.
  This can leave a stale socket and lose the indexed server between commands. Repeated commands
  must not be described as warm measurements without verifying process reuse. A regression
  test for a fix should close the client while an upstream notification is queued and verify
  that the daemon remains available to a subsequent client.

# LSP server implementations

## Mason PyPI launchers

- In isolated current-Mason runs, the generated launchers for basedpyright,
  jedi-language-server, python-lsp-server, and Pyre resolved from the package directory but the system
  Python interpreter could not import their installed modules. Provisioning tests that only prove
  executable resolution do not establish that a PyPI-backed language server can initialize.

## pylyzer

- In an isolated current-Mason run against the Python playground, pylyzer returned no immediate
  `workspace/symbol` matches and no incoming call-hierarchy edges for `build_sample_order`, while
  its other applicable semantic queries completed. Exhaustive coverage records those two empty
  results explicitly instead of treating advertised capabilities as a guarantee of fixture matches.

## pyright

- In an isolated current-Mason run against the Python playground, pyright advertised workspace
  symbols but returned no matches before background analysis completed. It also exposed no
  background-work completion signal usable by `build-index`; tests assert both bounded outcomes
  instead of adding a fixed indexing sleep.
- pyright does not implement `$/progress`/`workDoneProgress` or `experimental/serverStatus` over
  the language server protocol at all (it only reports progress via its separate CLI's
  `--outputjson` mode), so `wait_for_background_work` has no protocol signal to key off for this
  server specifically. A pyright-only readiness heuristic would need to open the target document
  and treat the first `textDocument/publishDiagnostics` for that URI as "analyzed enough", since
  pyright reliably publishes diagnostics (even an empty list) once it has actually checked a file.
  That is a per-server heuristic, not a real completion signal: it cannot distinguish "not analyzed
  yet" from "analyzed with zero diagnostics" until the notification actually arrives, so it still
  needs a bounded timeout and remains unverified against pyright's actual behavior.

## zuban

- Current-Mason Zuban answers the Python playground's semantic queries but exposes no
  background-work progress signal usable by `build-index`. Exhaustive coverage asserts the bounded
  user-facing failure rather than inferring that indexing has completed.

## ty

- Current-Mason ty returned an empty callers result in one isolated Python fixture run and a
  non-empty result in the next otherwise identical run. The exhaustive pair remains excluded until
  call-hierarchy results are deterministic enough for a stable expectation.

## Pyrefly

- Current-Mason Pyrefly initializes against the Python playground but returns no workspace
  symbols, document symbols, or document functions. The pair is excluded because later named
  queries cannot be given a meaningful semantic assertion without a discoverable symbol.

## deno lsp

- The current Mason Deno server initializes for JavaScript and TypeScript, but rejects the LSP
  `shutdown` request when its parameters are `null`, returning `-32602` and asking for non-null
  parameters. LSP 3.17 defines `shutdown` with no parameters, so direct lsp-cli commands currently
  report an unclean shutdown and the Deno pairs remain excluded.

## typescript-language-server

- Version 4.4.0 with TypeScript 6.0.3 advertises `workspaceSymbolProvider`, but a fresh
  `workspace/symbol` request can fail with `No Project` before any document has been opened. A
  capability-aware test must distinguish this advertised-but-not-yet-ready behavior from an
  unsupported capability.
- The server exposes no background-work progress notification usable by `build-index`; assert the
  bounded user-facing failure instead of sleeping or assuming that project analysis completed.

## vtsls

- In isolated current-Mason runs against the JavaScript and TypeScript playgrounds, vtsls returned
  no immediate workspace-symbol matches before project analysis completed and exposed no
  background-work completion notification usable by `build-index`. Exhaustive coverage records
  both bounded outcomes instead of relying on a fixed indexing delay.

## jdtls

- The current Mason jdtls launcher requires Java 21 or newer. Merely resolving a `java` executable
  is insufficient: GitHub's default Java may be older and makes the launcher exit before the LSP
  `initialize` response. CI must provision Java 21 explicitly before enabling the lifecycle case.
- The current Mason jdtls package needs both Java to run and Python to install its launcher. It can
  initialize and answer LSP requests, but a direct-process capability query timed out waiting for
  the server to exit after shutdown. Keep the preferred-pair test excluded until direct shutdown
  is reliable; exercise it through the separately planned detached lifecycle scenario.
- `stop` removes a jdtls daemon socket before the upstream Java process has necessarily completed
  shutdown. Immediately starting another jdtls for the same workspace can overlap the old process
  and stall initialization. Lifecycle tests wait, with a deadline, for the recorded upstream PID
  to exit after `stop`; socket disappearance alone does not prove complete process termination.

## kotlin-language-server

- The current Mason Kotlin Language Server launcher also needs `uname` and `xargs` in its isolated
  path. With those tools present it initializes and answers the request, but the process does not
  exit before the direct-run deadline after shutdown. Its exhaustive pair remains excluded until
  direct shutdown is reliable.

## kotlin-lsp

- The Mason package exposes Kotlin LSP as `intellij-server`, not `kotlin-lsp`. The data config must
  use the packaged launcher name so `--download` can resolve it; a display name or upstream product
  name is not necessarily an executable name.
- The Mason generic package uses `{{ version | strip_prefix "kotlin-lsp/v" }}` in its download URL
  and executable path. Mason version prefixes are package-specific, so lsp-cli's template renderer
  supports quoted `version | strip_prefix "<literal>"` expressions rather than special-casing the
  Kotlin prefix. Unsupported filters remain unresolved instead of being guessed.
- Mason version `kotlin-lsp/v262.9593.0` downloads and launches, but `intellij-server` reports that
  the build has expired and exits before completing LSP initialization. Detection and file listing
  still work, and a daemon can create its socket and be stopped, but capability and semantic checks
  are blocked until the registry provides a usable build.

## roslyn-language-server

- The Mason package exposes Roslyn through the `roslyn-language-server` .NET tool launcher. A data
  config containing a literal installation placeholder cannot work with generic `--download`;
  launch the Mason-exposed command and let the NuGet backend manage its concrete installation path.

## emmylua_ls

- Current-Mason EmmyLua answers semantic requests for the Lua playground, but its formatting
  response contains a line outside the requested file. lsp-cli correctly rejects that invalid edit;
  exhaustive coverage records the stable user-facing failure.
- The same run returned no immediate workspace-symbol matches and no outgoing call-hierarchy edges
  for the fixture's `format_timestamp` function. Those empty results are explicit pair exceptions,
  not evidence that the corresponding capabilities are universally unsupported.

## lua-language-server

- Opening every source file for symbol discovery also triggers diagnostics. The installed
  LuaLS `textDocument/didOpen` handler opens and compiles the file; its diagnostic provider
  runs diagnostics on file-open events. In a release-build `parley.nvim` experiment with 193
  Lua files and a request window of 20, an explicit temporary configuration with diagnostics
  enabled took 12.57/12.37 seconds; disabling only diagnostics took 4.50/2.84 seconds with
  identical reference matches. Traces showed zero diagnostic notifications in the disabled
  case. This is a server-specific experimental control, not a recommendation to silently
  disable diagnostics for normal commands or shared sessions.
- In a September 2026 investigation against `parley.nvim`, `workspace/symbol` returned null for
  the local function `normalize_timestamp`, while `textDocument/documentSymbol` exposed it and
  `textDocument/references` found its call site. Workspace-symbol results alone therefore cannot
  replace document-symbol discovery for this query.
- Two direct-process runs in that investigation completed their queries but failed while waiting
  for LuaLS to exit, adding the configured 30-second timeout. The debug trace showed a successful
  `shutdown` response followed by an `exit` notification. The cause of the process staying alive
  was not established; detached runs completed successfully. Do not assume switching off detach
  is a reliable performance workaround for this setup.

## rust-analyzer

- `rust-analyzer` may answer `textDocument/documentSymbol` with flat `SymbolInformation` items whose
  `location.range.start` points at the start of the whole declaration (for example `pub fn` or an
  attribute line) instead of the identifier itself. When a later LSP request needs a precise
  symbol position, prefer recovering the identifier offset from source text inside that range
  instead of assuming `range.start` is directly queryable.

## clangd

- `clangd` may start successfully without sending the background-work progress notifications that
  `lsp-cli` currently expects for `wait-for-index`/`build-index` flows. Keep normal symbol-query
  configs on `wait-for-index: false` unless that progress reporting is confirmed for the target
  `clangd` setup.
- `compile_commands.json` requires absolute working directories, which makes a committed database
  stale when an E2E project is copied. Portable clangd fixtures should use `compile_flags.txt` or
  generate the database after copying instead of committing checkout-specific paths or a relative
  `directory` value.
- clangd 22.1.6 returned document symbols, definitions, references, and call hierarchy for the
  CUDA, Objective-C, and Objective-C++ playgrounds, but an immediate `workspace/symbol` query
  returned no matches. It also exposed no progress signal usable by `build-index`; capability-aware
  tests need an explicit bounded policy rather than a fixed indexing sleep.
- `clangd` may expose diagnostics only through delayed `textDocument/publishDiagnostics` even when
  it does send `$/progress`, and in some setups it does not advertise `diagnosticProvider` for
  pull diagnostics at all. For `lsp-cli diag`, prefer pull diagnostics when the capability is
  advertised, but keep a timeout-bounded push fallback for `clangd`-style behavior.