3741 lines
149 KiB
Rust
3741 lines
149 KiB
Rust
// lsp.rs --- T M4.5 LSP client core.
|
|
|
|
//! Language Server Protocol client core. Spec §5.2 wire-format
|
|
//! channel reused for JSON-RPC 2.0 over `Content-Length`-framed
|
|
//! pipes. Sits on top of [`crate::process::ProcessSupervisor`]
|
|
//! (M4.4) for spawn / signal / restart / I/O; the supervisor's
|
|
//! per-pipe reader thread provides the parallelism --- the LSP
|
|
//! layer never reads from a pipe on the main thread.
|
|
//!
|
|
//! # Topology
|
|
//!
|
|
//! ```text
|
|
//! main thread supervisor (M4.4) LSP server
|
|
//! ----------- ------------------- -------------
|
|
//! LspManager ---> spawn + write_stdin ---> child stdin
|
|
//! ^ child stdout
|
|
//! +-- frame parse <--- Stdout(Vec<u8>) <------ reader thread
|
|
//! +-- frame parse <--- Stderr(Vec<u8>) <------ reader thread
|
|
//! ```
|
|
//!
|
|
//! The supervisor's reader thread already chunks pipe output off the
|
|
//! main thread; the LSP layer just buffers those chunks per server
|
|
//! and parses `Content-Length` frames out of them in [`LspClient::pump`].
|
|
//! Parsing is incremental --- a frame split across two `Stdout` events
|
|
//! is reassembled without re-allocating.
|
|
//!
|
|
//! # Lifecycle
|
|
//!
|
|
//! ```text
|
|
//! Starting --(supervisor: Started)--> Initializing
|
|
//! --(initialize response)--> Initialized { capabilities }
|
|
//! --(shutdown sent + exit)--> Stopped
|
|
//! --(unexpected exit / signal)--> Crashed
|
|
//! --(restart policy)--> Initializing (new pid)
|
|
//! ```
|
|
//!
|
|
//! Every transition emits an [`LspEvent`] visible through
|
|
//! [`LspManager::take_events`] / [`LspManager::take_all_events`].
|
|
//! Crashes restart automatically when the spec's [`LspRestartPolicy`]
|
|
//! says so; the restart re-runs `initialize` from scratch (LSP
|
|
//! servers do not survive their process).
|
|
//!
|
|
//! # Concurrency
|
|
//!
|
|
//! A single [`LspManager`] lives on the main thread inside the
|
|
//! editor's `Rc<RefCell<...>>`. It mutates one [`ProcessSupervisor`]
|
|
//! it shares with `pmacs.process.*`. All inbound bytes arrive on
|
|
//! supervisor reader threads (one per pipe); all outbound writes go
|
|
//! through `ProcessSupervisor::write_stdin` synchronously from the
|
|
//! main thread. Per-server traffic is small (LSP is request-driven,
|
|
//! kilobytes per second) so the synchronous write is acceptable for
|
|
//! v0.1; M5 may push outbound to a writer thread if profiling shows
|
|
//! it matters.
|
|
|
|
use std::cell::RefCell;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
use std::rc::Rc;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use serde_json::{Map, Value, json};
|
|
|
|
use crate::async_runtime::{JobId, JobKind, SharedAsyncRuntime};
|
|
use crate::process::{
|
|
ProcessEvent, ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState,
|
|
RestartPolicy, Termination,
|
|
};
|
|
use crate::worker::CancellationToken;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Identity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Stable identifier for one managed LSP server. Allocated in
|
|
/// monotonic order.
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
|
|
pub struct LspServerId(u64);
|
|
|
|
impl LspServerId {
|
|
/// Mint a fresh id.
|
|
#[must_use]
|
|
pub fn next() -> Self {
|
|
static COUNTER: AtomicU64 = AtomicU64::new(1);
|
|
Self(COUNTER.fetch_add(1, Ordering::Relaxed))
|
|
}
|
|
|
|
/// Raw counter value. Used for Lua boundary marshalling and
|
|
/// debug formatting.
|
|
#[must_use]
|
|
pub fn raw(self) -> u64 {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for LspServerId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "LspServerId({})", self.0)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Spec for one LSP server.
|
|
#[derive(Clone, Debug)]
|
|
pub struct LspServerSpec {
|
|
/// Human-readable label. Surfaced in events, the modeline, and
|
|
/// the `*lsp*` buffer (M4.8).
|
|
pub label: String,
|
|
/// Language id for `didOpen` / `didChange` (e.g. `"rust"`,
|
|
/// `"lua"`). LSP servers use this string to dispatch
|
|
/// language-specific behavior.
|
|
pub language_id: String,
|
|
/// Program to execute. PATH-resolved unless absolute.
|
|
pub command: String,
|
|
/// Argument vector.
|
|
pub args: Vec<String>,
|
|
/// Working directory for the spawn. Also the default `rootUri`
|
|
/// if [`Self::root_uri`] is `None`.
|
|
pub cwd: Option<PathBuf>,
|
|
/// Override for the root URI sent in `initialize`. `None` →
|
|
/// derive from `cwd` (or the editor's cwd if both are unset).
|
|
pub root_uri: Option<String>,
|
|
/// Environment overrides for the child process.
|
|
pub env: Vec<(String, String)>,
|
|
/// Optional `initializationOptions` sent in the `initialize`
|
|
/// request. Free-form per server; pmacs marshalls it into JSON
|
|
/// from a Lua table.
|
|
pub init_options: Option<Value>,
|
|
/// T M4.5: workspace settings answered to server→client
|
|
/// `workspace/configuration` pull requests. A JSON object;
|
|
/// requested `section`s (dotted paths, e.g. `python.analysis`)
|
|
/// resolve into it, unknown sections answer `null` per spec.
|
|
pub settings: Option<Value>,
|
|
/// Optional client-side capabilities override sent in
|
|
/// `initialize`. `None` falls back to a conservative built-in
|
|
/// default (text-sync full, hover, completion, definition).
|
|
pub capabilities: Option<Value>,
|
|
/// Restart policy for the server. Mirrors
|
|
/// [`crate::process::RestartPolicy`].
|
|
pub restart: LspRestartPolicy,
|
|
}
|
|
|
|
impl LspServerSpec {
|
|
/// Construct a spec with the bare-minimum fields.
|
|
#[must_use]
|
|
pub fn new(
|
|
label: impl Into<String>,
|
|
language_id: impl Into<String>,
|
|
command: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
label: label.into(),
|
|
language_id: language_id.into(),
|
|
command: command.into(),
|
|
args: Vec::new(),
|
|
cwd: None,
|
|
root_uri: None,
|
|
env: Vec::new(),
|
|
init_options: None,
|
|
settings: None,
|
|
capabilities: None,
|
|
restart: LspRestartPolicy::OnCrash,
|
|
}
|
|
}
|
|
|
|
fn to_process_spec(&self) -> ProcessSpec {
|
|
let mut p = ProcessSpec::new(format!("lsp:{}", self.label), &self.command);
|
|
p.args.clone_from(&self.args);
|
|
p.cwd.clone_from(&self.cwd);
|
|
p.env.clone_from(&self.env);
|
|
p.mode = ProcessMode::Pipes;
|
|
// We handle restart at the LSP layer (because we need to
|
|
// re-run `initialize` after each restart); the supervisor
|
|
// never restarts behind our back.
|
|
p.restart = RestartPolicy::Never;
|
|
p
|
|
}
|
|
}
|
|
|
|
/// What to do when the LSP server process terminates unexpectedly.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum LspRestartPolicy {
|
|
/// Never restart.
|
|
Never,
|
|
/// Restart on signal or non-zero exit.
|
|
OnCrash,
|
|
/// Restart on any termination (clean or otherwise).
|
|
Always,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lifecycle state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// LSP client state machine. One instance per managed server.
|
|
#[derive(Clone, Debug)]
|
|
pub enum LspClientState {
|
|
/// Process spawning; supervisor hasn't reported `Started` yet.
|
|
Starting,
|
|
/// Process is alive; `initialize` request is in flight.
|
|
Initializing {
|
|
/// JSON-RPC id of the in-flight `initialize` request.
|
|
init_request_id: u64,
|
|
/// When the server process started.
|
|
started: Instant,
|
|
},
|
|
/// `initialize` has been answered; `initialized` notification
|
|
/// has been sent. The server is ready for arbitrary requests.
|
|
Initialized {
|
|
/// `ServerCapabilities` returned by `initialize`. The full
|
|
/// JSON is preserved; consumers (M4.6+) project the bits
|
|
/// they need.
|
|
capabilities: Value,
|
|
/// `serverInfo` from the initialize result, if reported.
|
|
server_info: Option<Value>,
|
|
/// When the server reached this state.
|
|
initialized_at: Instant,
|
|
},
|
|
/// `shutdown` request has been sent; the client is waiting for
|
|
/// the response (after which it sends `exit`).
|
|
ShuttingDown {
|
|
/// Id of the in-flight shutdown request, or `None` once the
|
|
/// shutdown response has been observed and the client is
|
|
/// waiting for the process to exit.
|
|
shutdown_request_id: Option<u64>,
|
|
},
|
|
/// Process exited cleanly after a deliberate `exit` notification.
|
|
Stopped {
|
|
/// When the process exited.
|
|
ended: Instant,
|
|
},
|
|
/// Process died unexpectedly. The supervisor's [`Termination`]
|
|
/// is preserved so the modeline / status surface can report it.
|
|
Crashed {
|
|
/// Why it died.
|
|
reason: String,
|
|
/// When the supervisor reported the termination.
|
|
ended: Instant,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Events
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One LSP-layer event. Visible to the editor via
|
|
/// [`LspManager::take_events`] and to Lua via
|
|
/// `pmacs.lsp.events_take`.
|
|
#[derive(Clone, Debug)]
|
|
pub struct LspEvent {
|
|
/// Server the event belongs to.
|
|
pub server: LspServerId,
|
|
/// What happened.
|
|
pub kind: LspEventKind,
|
|
/// When the event was generated (monotonic).
|
|
pub at: Instant,
|
|
}
|
|
|
|
/// Discriminator over LSP-layer event kinds.
|
|
#[derive(Clone, Debug)]
|
|
pub enum LspEventKind {
|
|
/// Process spawned and is running. The `initialize` request was
|
|
/// just sent.
|
|
Started {
|
|
/// OS pid of the new generation.
|
|
pid: u32,
|
|
},
|
|
/// `initialize` response was received and `initialized`
|
|
/// notification was sent.
|
|
Initialized {
|
|
/// `ServerCapabilities` from the response.
|
|
capabilities: Value,
|
|
},
|
|
/// Server-originated notification (e.g.
|
|
/// `textDocument/publishDiagnostics`, `window/logMessage`).
|
|
Notification {
|
|
/// JSON-RPC method name.
|
|
method: String,
|
|
/// Params payload (already JSON-decoded).
|
|
params: Value,
|
|
},
|
|
/// Server-originated request (rare in v0.1; needed for
|
|
/// `window/workDoneProgress/create`, `client/registerCapability`).
|
|
/// Pmacs replies with a default error for any request it doesn't
|
|
/// know how to handle so the protocol stays alive.
|
|
Request {
|
|
/// Inbound id; the client will respond with this id.
|
|
id: Value,
|
|
/// JSON-RPC method.
|
|
method: String,
|
|
/// Params payload.
|
|
params: Value,
|
|
},
|
|
/// Response to a client-initiated request was received.
|
|
Response {
|
|
/// Id of the original request (echoed back).
|
|
id: u64,
|
|
/// `result` field; `Null` if absent.
|
|
result: Value,
|
|
/// `error` field, if any (in which case `result` is `Null`).
|
|
error: Option<LspError>,
|
|
/// Method of the original request, copied here for
|
|
/// convenience so consumers don't have to track ids.
|
|
method: String,
|
|
},
|
|
/// `shutdown` response received and `exit` notification was
|
|
/// sent. The next `Process` event observed will be the actual
|
|
/// exit.
|
|
ShuttingDown,
|
|
/// Server process exited cleanly. Final state for this
|
|
/// generation.
|
|
Stopped,
|
|
/// Server process died (signal or non-zero exit not preceded by
|
|
/// `shutdown`).
|
|
Crashed {
|
|
/// Display-formatted reason.
|
|
reason: String,
|
|
},
|
|
/// The manager is about to restart this server.
|
|
Restarting {
|
|
/// Cumulative spawn attempt for this server (1 = first
|
|
/// spawn, 2 = first restart, ...).
|
|
attempt: u32,
|
|
},
|
|
/// Bytes from the server's stderr. Servers commonly write log
|
|
/// chatter here. Treated as opaque for now; M4.8 stitches it
|
|
/// into `*lsp*`.
|
|
Stderr(Vec<u8>),
|
|
/// A protocol violation was observed and recovered from.
|
|
/// Reported separately so the status surface can flag the
|
|
/// server as misbehaving.
|
|
ProtocolError {
|
|
/// Display-formatted description of what went wrong.
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
/// JSON-RPC error object (`{code, message, data?}`).
|
|
#[derive(Clone, Debug)]
|
|
pub struct LspError {
|
|
/// Numeric error code. LSP defines several standard codes
|
|
/// (-32000 to -32099 for server errors, etc.).
|
|
pub code: i64,
|
|
/// Human-readable message.
|
|
pub message: String,
|
|
/// Optional `data` field, free-form JSON.
|
|
pub data: Option<Value>,
|
|
}
|
|
|
|
impl LspError {
|
|
fn from_value(v: &Value) -> Self {
|
|
let code = v.get("code").and_then(Value::as_i64).unwrap_or(0);
|
|
let message = v
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_owned();
|
|
let data = v.get("data").cloned();
|
|
Self {
|
|
code,
|
|
message,
|
|
data,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Frame codec
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Incremental `Content-Length`-framed parser for JSON-RPC over
|
|
/// pipes. Holds a single `Vec<u8>` buffer that grows with appended
|
|
/// bytes and shrinks (via memmove) when complete frames are
|
|
/// extracted. Designed for the throughput LSPs actually produce
|
|
/// (kilobytes/sec); the buffer is rarely larger than the largest
|
|
/// pending frame.
|
|
#[derive(Default)]
|
|
pub struct FrameParser {
|
|
buf: Vec<u8>,
|
|
}
|
|
|
|
impl FrameParser {
|
|
/// New empty parser.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self { buf: Vec::new() }
|
|
}
|
|
|
|
/// Append `chunk` to the internal buffer.
|
|
pub fn extend(&mut self, chunk: &[u8]) {
|
|
self.buf.extend_from_slice(chunk);
|
|
}
|
|
|
|
/// Pop one complete frame off the front of the buffer if one is
|
|
/// available, returning its body bytes. Returns `Ok(None)` when
|
|
/// no complete frame is buffered yet, and `Err` for an
|
|
/// unrecoverable framing error (in which case the caller is
|
|
/// expected to re-spawn the server; staying on the same byte
|
|
/// stream after a frame violation is unsafe).
|
|
pub fn next_frame(&mut self) -> Result<Option<Vec<u8>>, FrameError> {
|
|
// Find the header terminator `\r\n\r\n`.
|
|
let Some(header_end) = find_subsequence(&self.buf, b"\r\n\r\n") else {
|
|
return Ok(None);
|
|
};
|
|
let header_str =
|
|
std::str::from_utf8(&self.buf[..header_end]).map_err(|_| FrameError::HeaderNotUtf8)?;
|
|
let mut content_length: Option<usize> = None;
|
|
for line in header_str.split("\r\n") {
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
let (key, value) = line
|
|
.split_once(':')
|
|
.ok_or_else(|| FrameError::MalformedHeader(line.to_owned()))?;
|
|
if key.trim().eq_ignore_ascii_case("content-length") {
|
|
let n: usize = value
|
|
.trim()
|
|
.parse()
|
|
.map_err(|_| FrameError::MalformedHeader(line.to_owned()))?;
|
|
content_length = Some(n);
|
|
}
|
|
// Other headers (e.g. `Content-Type: ...`) are ignored.
|
|
}
|
|
let body_len = content_length.ok_or(FrameError::MissingContentLength)?;
|
|
let frame_total = header_end + 4 + body_len;
|
|
if self.buf.len() < frame_total {
|
|
return Ok(None);
|
|
}
|
|
let body = self.buf[header_end + 4..frame_total].to_vec();
|
|
// Consume the frame from the front of the buffer.
|
|
self.buf.drain(..frame_total);
|
|
Ok(Some(body))
|
|
}
|
|
}
|
|
|
|
/// Fatal framing error.
|
|
#[derive(Debug)]
|
|
pub enum FrameError {
|
|
/// Header bytes weren't valid UTF-8 (LSP requires ASCII headers
|
|
/// but UTF-8 is the same in that range).
|
|
HeaderNotUtf8,
|
|
/// A header line was malformed (no colon, or `Content-Length`
|
|
/// value didn't parse as a number).
|
|
MalformedHeader(String),
|
|
/// The header block contained no `Content-Length` field; we
|
|
/// have no way to know how many bytes to read.
|
|
MissingContentLength,
|
|
}
|
|
|
|
impl std::fmt::Display for FrameError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::HeaderNotUtf8 => write!(f, "frame header was not valid UTF-8"),
|
|
Self::MalformedHeader(line) => write!(f, "malformed header line: {line:?}"),
|
|
Self::MissingContentLength => write!(f, "frame missing Content-Length header"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for FrameError {}
|
|
|
|
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
|
haystack
|
|
.windows(needle.len())
|
|
.position(|window| window == needle)
|
|
}
|
|
|
|
/// Encode a JSON-RPC body as a complete `Content-Length`-framed
|
|
/// message. Returns the bytes ready to write to the server's stdin.
|
|
#[must_use]
|
|
pub fn encode_frame(body: &[u8]) -> Vec<u8> {
|
|
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
|
let mut out = Vec::with_capacity(header.len() + body.len());
|
|
out.extend_from_slice(header.as_bytes());
|
|
out.extend_from_slice(body);
|
|
out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LspClient
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Negotiated LSP position encoding (LSP 3.17 `general.positionEncoding`).
|
|
/// The `character` field of every `Position` is counted in these
|
|
/// units. pmacs works in UTF-8 byte offsets everywhere internally
|
|
/// (rope, cursor, stores, Lua); this drives the one conversion at the
|
|
/// request/response boundary so every consumer stays byte-uniform.
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)]
|
|
pub enum PositionEncoding {
|
|
/// `character` counts UTF-8 code units (== bytes). Conversion is
|
|
/// identity — pmacs's native representation. Advertised first so
|
|
/// servers that support it (rust-analyzer, clangd) pick the
|
|
/// zero-cost path.
|
|
Utf8,
|
|
/// `character` counts UTF-16 code units. The LSP spec default
|
|
/// when a server does not negotiate (gopls / pyright / older
|
|
/// servers). Requires per-line code-unit walking.
|
|
#[default]
|
|
Utf16,
|
|
}
|
|
|
|
impl PositionEncoding {
|
|
/// Map a negotiated `general.positionEncoding` string to the
|
|
/// enum. Anything other than `"utf-8"` (including `"utf-16"`,
|
|
/// `"utf-32"` which we do not advertise, or absent/garbage)
|
|
/// resolves to the spec default, UTF-16.
|
|
fn from_negotiated(s: Option<&str>) -> Self {
|
|
match s {
|
|
Some("utf-8") => Self::Utf8,
|
|
_ => Self::Utf16,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolve a `workspace/configuration` item's `section` against the
|
|
/// server's `settings`. LSP semantics: a dotted `section`
|
|
/// (`"python.analysis"`) walks nested objects; an absent/empty
|
|
/// section asks for the whole settings object; a section that does
|
|
/// not resolve answers `null` (the spec's "scope not configured"
|
|
/// signal — distinct from a configured `null`/`false`).
|
|
fn resolve_config_section(settings: &Value, section: Option<&str>) -> Value {
|
|
match section {
|
|
None | Some("") => settings.clone(),
|
|
Some(path) => {
|
|
let mut cur = settings;
|
|
for key in path.split('.') {
|
|
match cur.get(key) {
|
|
Some(v) => cur = v,
|
|
None => return Value::Null,
|
|
}
|
|
}
|
|
cur.clone()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The 0-based `line`-th `\n`-delimited slice of `text`, or `None`
|
|
/// when `line` is past EOF. The distinction matters: a genuinely
|
|
/// empty line (`Some("")`) is converted to byte 0, but a line we do
|
|
/// not have (`None` — e.g. a cross-file definition into a document
|
|
/// we never cached) must be left **unconverted** rather than
|
|
/// collapsed to 0, so its coordinates are not corrupted. A trailing
|
|
/// `\r` (CRLF) stays in the slice; both the pmacs byte offset and the
|
|
/// server `character` are line-relative so the `\r` cancels out.
|
|
fn nth_line(text: &str, line: u32) -> Option<&str> {
|
|
text.split('\n').nth(line as usize)
|
|
}
|
|
|
|
/// Snap `byte` down to the nearest char boundary of `line` (defensive
|
|
/// against a server, in UTF-8 mode, reporting a mid-codepoint offset).
|
|
fn floor_boundary(line: &str, mut byte: usize) -> usize {
|
|
if byte >= line.len() {
|
|
return line.len();
|
|
}
|
|
while byte > 0 && !line.is_char_boundary(byte) {
|
|
byte -= 1;
|
|
}
|
|
byte
|
|
}
|
|
|
|
/// Inbound: server `character` (in `enc` units, line-relative) → the
|
|
/// pmacs byte offset within `line`. Clamps past-EOL to `line.len()`.
|
|
///
|
|
/// `pub(crate)` so the semantic-render producer can reuse the exact
|
|
/// same encoding conversion for LSP semantic tokens (whose
|
|
/// `start`/`length` are *not* byte-rewritten by the absorb path —
|
|
/// see [`LspManager::semantic_style_context`]).
|
|
pub(crate) fn char_to_byte(line: &str, character: u32, enc: PositionEncoding) -> usize {
|
|
match enc {
|
|
PositionEncoding::Utf8 => floor_boundary(line, character as usize),
|
|
PositionEncoding::Utf16 => {
|
|
let mut units = 0u32;
|
|
for (byte_idx, ch) in line.char_indices() {
|
|
let cu = ch.len_utf16() as u32;
|
|
// If the target unit falls *within* this char (incl. a
|
|
// server pointing mid-surrogate-pair), clamp to the
|
|
// char's start rather than overshooting to the next.
|
|
if character < units + cu {
|
|
return byte_idx;
|
|
}
|
|
units += cu;
|
|
}
|
|
line.len()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Outbound: pmacs byte offset within `line` → server `character` in
|
|
/// `enc` units. `byte_col` is clamped to the line and snapped to a
|
|
/// char boundary first.
|
|
fn byte_to_char(line: &str, byte_col: usize, enc: PositionEncoding) -> u32 {
|
|
let byte_col = floor_boundary(line, byte_col);
|
|
match enc {
|
|
PositionEncoding::Utf8 => byte_col as u32,
|
|
PositionEncoding::Utf16 => {
|
|
let mut units = 0u32;
|
|
for (byte_idx, ch) in line.char_indices() {
|
|
if byte_idx >= byte_col {
|
|
break;
|
|
}
|
|
units += ch.len_utf16() as u32;
|
|
}
|
|
units
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Recursively rewrite every LSP `Position` (`{ line, character }`)
|
|
/// in `value` so `character` becomes a pmacs byte offset instead of
|
|
/// a count in `enc` units. In LSP the `(line, character)` key pair
|
|
/// uniquely identifies a `Position` — no other structure carries
|
|
/// both — so a structural walk is exact: `Range`s, `Location`s,
|
|
/// `TextEdit`s, diagnostics, hover ranges all nest `Position`s and
|
|
/// are converted uniformly. UTF-8 is a no-op fast path (the offsets
|
|
/// already match), so the recursion is skipped entirely there.
|
|
fn rewrite_positions_to_bytes(value: &mut Value, doc: &str, enc: PositionEncoding) {
|
|
if enc == PositionEncoding::Utf8 {
|
|
return;
|
|
}
|
|
match value {
|
|
Value::Object(map) => {
|
|
if let (Some(line), Some(character)) = (
|
|
map.get("line").and_then(Value::as_u64),
|
|
map.get("character").and_then(Value::as_u64),
|
|
) && let Some(line_text) = nth_line(doc, line as u32)
|
|
{
|
|
// Line absent from the cached doc (cross-file /
|
|
// not-yet-opened) ⇒ leave the position untouched.
|
|
let byte = char_to_byte(line_text, character as u32, enc);
|
|
map.insert("character".into(), Value::from(byte));
|
|
}
|
|
for v in map.values_mut() {
|
|
rewrite_positions_to_bytes(v, doc, enc);
|
|
}
|
|
}
|
|
Value::Array(arr) => {
|
|
for v in arr.iter_mut() {
|
|
rewrite_positions_to_bytes(v, doc, enc);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// One managed LSP server: process + JSON-RPC framing + state
|
|
/// machine. Owned by [`LspManager`]; the supervisor handles process
|
|
/// I/O underneath.
|
|
pub struct LspClient {
|
|
spec: LspServerSpec,
|
|
state: LspClientState,
|
|
process: Option<ProcessId>,
|
|
stdout: FrameParser,
|
|
/// JSON-RPC request id counter. Monotonically increasing.
|
|
next_request_id: u64,
|
|
/// Pending requests we sent and are waiting on a response for.
|
|
/// Maps request id → method (so the response event can echo
|
|
/// the method name back to the consumer).
|
|
pending: HashMap<u64, String>,
|
|
/// Cumulative spawn attempts (1 = first spawn, 2 = first
|
|
/// restart, ...).
|
|
attempt: u32,
|
|
/// When the manager should attempt the next restart, if any.
|
|
next_restart_at: Option<Instant>,
|
|
/// T M4.5: request ids we sent `$/cancelRequest` for after every
|
|
/// awaiter abandoned them. A server may still answer a cancelled
|
|
/// request (the cancel/response race); a late response whose id
|
|
/// is in this set is dropped silently instead of surfacing a
|
|
/// "response for unknown request id" `ProtocolError`. Mirrors
|
|
/// `mcp.rs`'s `cancelled_rids`.
|
|
cancelled_rids: HashSet<u64>,
|
|
/// Notifications issued before the server reached `Initialized`.
|
|
/// The LSP lifecycle requires `initialize` / `initialized` to
|
|
/// complete before any other notification; a strict server
|
|
/// (clangd) silently discards a pre-init `textDocument/didOpen`,
|
|
/// after which every later request fails with
|
|
/// `-32602 trying to get AST for non-added document`. These are
|
|
/// held in issue order while `Starting` / `Initializing` and
|
|
/// replayed the instant the server reaches `Initialized` (right
|
|
/// after the `initialized` notification is sent).
|
|
deferred_notifications: Vec<(String, Value)>,
|
|
/// T M4.5 Option B: encoding the server negotiated for `Position`
|
|
/// `character` counts. Set from the `initialize` response;
|
|
/// defaults to the LSP spec default (UTF-16) until then.
|
|
position_encoding: PositionEncoding,
|
|
}
|
|
|
|
impl LspClient {
|
|
fn new(spec: LspServerSpec) -> Self {
|
|
Self {
|
|
spec,
|
|
state: LspClientState::Starting,
|
|
process: None,
|
|
stdout: FrameParser::new(),
|
|
next_request_id: 1,
|
|
pending: HashMap::new(),
|
|
attempt: 0,
|
|
next_restart_at: None,
|
|
cancelled_rids: HashSet::new(),
|
|
deferred_notifications: Vec::new(),
|
|
position_encoding: PositionEncoding::default(),
|
|
}
|
|
}
|
|
|
|
/// Current spec.
|
|
#[must_use]
|
|
pub fn spec(&self) -> &LspServerSpec {
|
|
&self.spec
|
|
}
|
|
|
|
/// Current lifecycle state.
|
|
#[must_use]
|
|
pub fn state(&self) -> &LspClientState {
|
|
&self.state
|
|
}
|
|
|
|
/// Process id of the running generation, if any.
|
|
#[must_use]
|
|
pub fn process(&self) -> Option<ProcessId> {
|
|
self.process
|
|
}
|
|
|
|
/// Server capabilities once initialized; `None` otherwise.
|
|
#[must_use]
|
|
pub fn capabilities(&self) -> Option<&Value> {
|
|
match &self.state {
|
|
LspClientState::Initialized { capabilities, .. } => Some(capabilities),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LspManager
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Per-editor registry of [`LspClient`]s. Owns no threads of its
|
|
/// own; ticks via [`Self::tick`] from the run loop.
|
|
pub struct LspManager {
|
|
supervisor: crate::lua_bindings::SharedProcessSupervisor,
|
|
clients: HashMap<LspServerId, LspClient>,
|
|
/// Reverse lookup: which server is which process.
|
|
process_to_server: HashMap<ProcessId, LspServerId>,
|
|
/// Per-server pending event buffer.
|
|
pending: HashMap<LspServerId, Vec<LspEvent>>,
|
|
/// Constant restart back-off (mirrors the supervisor's default).
|
|
restart_backoff: Duration,
|
|
/// T M4.6 diagnostic store. Populated as the manager handles
|
|
/// `textDocument/publishDiagnostics` notifications. Shared with
|
|
/// every [`crate::diag::DiagnosticView`] attached to a buffer.
|
|
diag_store: crate::diag::SharedDiagStore,
|
|
/// T M4.7 completion store. Populated when a
|
|
/// `textDocument/completion` response lands.
|
|
completion_store: crate::completion::SharedCompletionStore,
|
|
/// T M4.7 hover store.
|
|
hover_store: crate::hover::SharedHoverStore,
|
|
/// T M4.7 signature-help store.
|
|
signature_store: crate::signature::SharedSignatureStore,
|
|
/// T M4.12 definition store. Populated when a
|
|
/// `textDocument/definition` response lands.
|
|
definition_store: crate::definition::SharedDefinitionStore,
|
|
/// T M4.5: references / declaration / typeDefinition /
|
|
/// implementation. Same Location shape as `definition`, keyed
|
|
/// additionally by kind so the four don't collide.
|
|
locations_store: crate::locations::SharedLocationsStore,
|
|
/// T M4.5: documentSymbol / workspace symbol, scope-keyed.
|
|
symbol_store: crate::symbol::SharedSymbolStore,
|
|
/// T M4.5: textDocument/documentHighlight, per `(server, uri)`.
|
|
document_highlight_store: crate::document_highlight::SharedDocumentHighlightStore,
|
|
/// T M4.12 formatting store. Populated when a
|
|
/// `textDocument/formatting` response lands.
|
|
formatting_store: crate::formatting::SharedFormattingStore,
|
|
/// T M4.5 L2 rename store. Populated when a `textDocument/rename`
|
|
/// response (a `WorkspaceEdit`) lands, keyed by the origin uri.
|
|
rename_store: crate::rename::SharedRenameStore,
|
|
/// T M4.5 prepareRename store. Populated when a
|
|
/// `textDocument/prepareRename` response lands, keyed by `(server,
|
|
/// uri)`.
|
|
prepare_rename_store: crate::prepare_rename::SharedPrepareRenameStore,
|
|
/// T M4.5 L3 code-action store. Populated when a
|
|
/// `textDocument/codeAction` response lands, keyed by `(server,
|
|
/// uri)`.
|
|
code_action_store: crate::code_action::SharedCodeActionStore,
|
|
/// T M4.5 inlay-hint store. Populated when a
|
|
/// `textDocument/inlayHint` response lands, keyed by `(server,
|
|
/// uri)`.
|
|
inlay_hint_store: crate::inlay_hint::SharedInlayHintStore,
|
|
/// T M4.5 semantic-token store. Populated when a
|
|
/// `textDocument/semanticTokens/full` response lands, keyed by
|
|
/// `(server, uri)`.
|
|
semantic_token_store: crate::semantic_tokens::SharedSemanticTokenStore,
|
|
/// Per-server request id → response routing target.
|
|
/// `request_completion` etc. record an entry here; `handle_response`
|
|
/// consumes it to absorb the response into the correct store.
|
|
pending_routes: HashMap<(LspServerId, u64), ResponseRoute>,
|
|
/// T M4.5 async bridge: `(server, request_id)` → the awaiters
|
|
/// parked on this request's async-runtime job(s). Settled in
|
|
/// [`Self::handle_response`] when the response routes, and
|
|
/// drained-cancelled wherever [`Self::pending_routes`] is purged
|
|
/// for a server (restart / exit / forget) so a coroutine never
|
|
/// parks forever on a server that went away.
|
|
pending_external: HashMap<(LspServerId, u64), PendingExternal>,
|
|
/// T M4.5 Option B: latest full text per `(server, uri)`,
|
|
/// mirrored from the `did_open` / `did_change_full` we send. The
|
|
/// only thing that lets the position codec convert between the
|
|
/// server's `character` units and pmacs byte offsets per line.
|
|
/// Dropped on `did_close` and at every server-teardown site.
|
|
documents: HashMap<(LspServerId, String), String>,
|
|
/// Async runtime handle. The bridge between the supervisor
|
|
/// reader-thread response delivery and Lua-side `Handle:await()`
|
|
/// resumption; mirrors [`crate::mcp::McpManager`]'s `runtime`.
|
|
runtime: SharedAsyncRuntime,
|
|
/// T M4.5 task #8: hard ceiling on how long an in-flight request
|
|
/// may park its awaiters before the sweep fails them with a
|
|
/// timeout. Generous by default (real servers can take seconds on
|
|
/// a cold cache); tunable from Lua via `pmacs.lsp.set_request_timeout_ms`.
|
|
request_timeout: Duration,
|
|
/// T M4.8 status tracker. Folds every emitted [`LspEvent`] into a
|
|
/// per-server [`crate::lsp_status::LspStatus`] for the modeline /
|
|
/// `*lsp*` buffer.
|
|
status_tracker: crate::lsp_status::LspStatusTracker,
|
|
/// T M4.9: `(project_root, language_id)` → server id. Drives the
|
|
/// "LSP runs per-project, not per-buffer" invariant. Roots are
|
|
/// stored as [`PathBuf`] so callers don't have to canonicalise
|
|
/// at every call site --- [`canonicalize_root_for_scope`] does
|
|
/// it once before insert.
|
|
project_servers: HashMap<(PathBuf, String), LspServerId>,
|
|
}
|
|
|
|
/// Routing target for a deferred response. Indexed by `(server, request_id)`
|
|
/// in [`LspManager::pending_routes`].
|
|
#[derive(Clone, Debug)]
|
|
enum ResponseRoute {
|
|
/// Absorb response into [`crate::completion::CompletionStore`] at
|
|
/// `(server, uri)`.
|
|
Completion { uri: String },
|
|
/// Absorb response into [`crate::hover::HoverStore`] at
|
|
/// `(server, uri)`.
|
|
Hover { uri: String },
|
|
/// Absorb response into [`crate::signature::SignatureStore`] at
|
|
/// `(server, uri)`.
|
|
Signature { uri: String },
|
|
/// Absorb response into [`crate::definition::DefinitionStore`] at
|
|
/// `(server, uri)`.
|
|
Definition { uri: String },
|
|
/// Absorb response into [`crate::formatting::FormattingStore`] at
|
|
/// `(server, uri)`.
|
|
Formatting { uri: String },
|
|
/// Absorb a `textDocument/rename` `WorkspaceEdit` into
|
|
/// [`crate::rename::RenameStore`] at `(server, origin uri)`.
|
|
Rename { uri: String },
|
|
/// Absorb a `textDocument/prepareRename` response into
|
|
/// [`crate::prepare_rename::PrepareRenameStore`] at `(server,
|
|
/// uri)`.
|
|
PrepareRename { uri: String },
|
|
/// Absorb a `textDocument/codeAction` response into
|
|
/// [`crate::code_action::CodeActionStore`] at `(server, uri)`.
|
|
CodeAction { uri: String },
|
|
/// Absorb a `textDocument/inlayHint` response into
|
|
/// [`crate::inlay_hint::InlayHintStore`] at `(server, uri)`.
|
|
InlayHint { uri: String },
|
|
/// Absorb a `textDocument/semanticTokens/full` (or `/range`)
|
|
/// response into [`crate::semantic_tokens::SemanticTokenStore`]
|
|
/// at `(server, uri)`.
|
|
SemanticTokens { uri: String },
|
|
/// Absorb a `textDocument/semanticTokens/full/delta` response —
|
|
/// spliced against the store's retained raw int stream at
|
|
/// `(server, uri)` — back into that same entry.
|
|
SemanticTokensDelta { uri: String },
|
|
/// Absorb a Location-shaped nav response (references / declaration
|
|
/// / typeDefinition / implementation) into
|
|
/// [`crate::locations::LocationsStore`] at `(server, uri, kind)`.
|
|
Locations {
|
|
/// Document URI.
|
|
uri: String,
|
|
/// Which nav request this answers.
|
|
kind: crate::locations::LocationKind,
|
|
},
|
|
/// Absorb a `textDocument/documentSymbol` response into
|
|
/// [`crate::symbol::SymbolStore`] at `(server, document uri)`.
|
|
DocumentSymbol {
|
|
/// The requested document URI (also the codec doc key, and
|
|
/// the fallback URI for `DocumentSymbol` items which carry
|
|
/// none).
|
|
uri: String,
|
|
},
|
|
/// Absorb a `workspace/symbol` response into
|
|
/// [`crate::symbol::SymbolStore`] at `(server, query)`.
|
|
WorkspaceSymbol {
|
|
/// The query string this answers.
|
|
query: String,
|
|
},
|
|
/// Absorb a `textDocument/documentHighlight` response into
|
|
/// [`crate::document_highlight::DocumentHighlightStore`].
|
|
DocumentHighlight {
|
|
/// Document URI.
|
|
uri: String,
|
|
},
|
|
}
|
|
|
|
impl ResponseRoute {
|
|
/// The document URI this route targets — the key for the position
|
|
/// codec's document/encoding lookup.
|
|
fn uri(&self) -> &str {
|
|
match self {
|
|
ResponseRoute::Completion { uri }
|
|
| ResponseRoute::Hover { uri }
|
|
| ResponseRoute::Signature { uri }
|
|
| ResponseRoute::Definition { uri }
|
|
| ResponseRoute::Formatting { uri }
|
|
| ResponseRoute::Rename { uri }
|
|
| ResponseRoute::PrepareRename { uri }
|
|
| ResponseRoute::CodeAction { uri }
|
|
| ResponseRoute::InlayHint { uri }
|
|
| ResponseRoute::SemanticTokens { uri }
|
|
| ResponseRoute::SemanticTokensDelta { uri }
|
|
| ResponseRoute::Locations { uri, .. }
|
|
| ResponseRoute::DocumentSymbol { uri }
|
|
| ResponseRoute::DocumentHighlight { uri } => uri,
|
|
// workspace/symbol results span arbitrary files we have
|
|
// not cached — no doc to convert against, so the inbound
|
|
// codec must pass coordinates through untouched (same
|
|
// non-destructive rule as cross-file definition).
|
|
ResponseRoute::WorkspaceSymbol { .. } => "",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One Lua-visible awaiter bound to an in-flight LSP request. Mirrors
|
|
/// [`crate::mcp`]'s `Awaiter`: each carries its own
|
|
/// [`CancellationToken`] (minted by [`SharedAsyncRuntime::register_external`])
|
|
/// so a single handle can be cancelled without disturbing the
|
|
/// in-flight wire request or sibling awaiters.
|
|
#[derive(Clone, Debug)]
|
|
struct Awaiter {
|
|
job_id: JobId,
|
|
/// Per-awaiter cancellation token minted by `register_external`.
|
|
/// Flipped by `Handle:cancel()` or by supersede; the per-tick
|
|
/// [`LspManager::drain_cancelled_externals`] sweep observes it.
|
|
/// Mirrors `mcp.rs`'s `Awaiter::token`.
|
|
token: CancellationToken,
|
|
}
|
|
|
|
/// In-flight `textDocument/*` request bound to one or more
|
|
/// async-runtime jobs. When the JSON-RPC response is routed in
|
|
/// [`LspManager::handle_response`] the response is absorbed into the
|
|
/// typed store (the hybrid model — popup/gutter consumers are
|
|
/// untouched) *and* every non-cancelled awaiter is settled via
|
|
/// [`SharedAsyncRuntime::complete_external_ok`] (or `_failed` on a
|
|
/// server error). Keyed `(server, request_id)` alongside
|
|
/// [`LspManager::pending_routes`]. T M4.5 async bridge.
|
|
#[derive(Clone, Debug)]
|
|
struct PendingExternal {
|
|
/// JSON-RPC method, kept for the `*workers*`/`*lsp*` observability
|
|
/// surface and protocol-error messages.
|
|
method: String,
|
|
/// Live awaiters. Never empty while this entry exists. The first
|
|
/// is the original caller; later entries are siblings.
|
|
awaiters: Vec<Awaiter>,
|
|
/// When the request went on the wire. T M4.5 task #8: the
|
|
/// per-tick sweep fails awaiters whose request has outlived
|
|
/// [`LspManager::request_timeout`], so a wedged-but-alive server
|
|
/// can't park a coroutine forever (the pre-v1.0 `poll_until` had
|
|
/// 500/1000 ms ceilings; this is the async-era equivalent, but a
|
|
/// generous hard ceiling rather than a UX poll budget).
|
|
dispatched_at: Instant,
|
|
}
|
|
|
|
/// Per-server styling context for one URI's semantic tokens,
|
|
/// resolved by [`LspManager::semantic_style_context`]. Decouples the
|
|
/// semantic-render producer from `LspServerId` (kept opaque) while
|
|
/// still giving it everything it needs to turn raw tokens into
|
|
/// byte-anchored, named style spans.
|
|
#[derive(Clone, Debug)]
|
|
pub struct SemanticStyleContext {
|
|
/// The owning server's negotiated position encoding. The
|
|
/// producer converts token `start`/`length` from these units to
|
|
/// pmacs byte offsets (a no-op when it is `Utf8`).
|
|
pub encoding: PositionEncoding,
|
|
/// The server's `semanticTokensProvider.legend`, if advertised.
|
|
/// `None` ⇒ the producer falls back to raw type indices (the
|
|
/// same degradation `lsp.lua`'s summary already tolerates).
|
|
pub legend: Option<crate::semantic_tokens::SemanticTokensLegend>,
|
|
}
|
|
|
|
impl LspManager {
|
|
/// Construct a fresh manager wired to `supervisor` and the
|
|
/// editor's `runtime`. The runtime bridges the supervisor's
|
|
/// reader-thread response delivery to Lua-side `Handle:await()`
|
|
/// resumption (mirrors [`crate::mcp::McpManager::new`]).
|
|
#[must_use]
|
|
pub fn new(
|
|
supervisor: crate::lua_bindings::SharedProcessSupervisor,
|
|
runtime: SharedAsyncRuntime,
|
|
) -> Self {
|
|
Self {
|
|
supervisor,
|
|
runtime,
|
|
clients: HashMap::new(),
|
|
process_to_server: HashMap::new(),
|
|
pending: HashMap::new(),
|
|
pending_external: HashMap::new(),
|
|
documents: HashMap::new(),
|
|
request_timeout: Duration::from_secs(10),
|
|
restart_backoff: Duration::from_millis(500),
|
|
diag_store: crate::diag::make_shared_store(),
|
|
completion_store: crate::completion::make_shared_store(),
|
|
hover_store: crate::hover::make_shared_store(),
|
|
signature_store: crate::signature::make_shared_store(),
|
|
definition_store: crate::definition::make_shared_store(),
|
|
locations_store: crate::locations::make_shared_store(),
|
|
symbol_store: crate::symbol::make_shared_store(),
|
|
document_highlight_store: crate::document_highlight::make_shared_store(),
|
|
formatting_store: crate::formatting::make_shared_store(),
|
|
rename_store: crate::rename::make_shared_store(),
|
|
prepare_rename_store: crate::prepare_rename::make_shared_store(),
|
|
code_action_store: crate::code_action::make_shared_store(),
|
|
inlay_hint_store: crate::inlay_hint::make_shared_store(),
|
|
semantic_token_store: crate::semantic_tokens::make_shared_store(),
|
|
pending_routes: HashMap::new(),
|
|
status_tracker: crate::lsp_status::LspStatusTracker::new(),
|
|
project_servers: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Shared diagnostic store. Cheap to clone; used by every
|
|
/// [`crate::diag::DiagnosticView`] attached to a buffer.
|
|
#[must_use]
|
|
pub fn diag_store(&self) -> crate::diag::SharedDiagStore {
|
|
self.diag_store.clone()
|
|
}
|
|
|
|
/// Shared completion store. Used by every
|
|
/// [`crate::completion::CompletionView`].
|
|
#[must_use]
|
|
pub fn completion_store(&self) -> crate::completion::SharedCompletionStore {
|
|
self.completion_store.clone()
|
|
}
|
|
|
|
/// Shared hover store. Used by every [`crate::hover::HoverView`].
|
|
#[must_use]
|
|
pub fn hover_store(&self) -> crate::hover::SharedHoverStore {
|
|
self.hover_store.clone()
|
|
}
|
|
|
|
/// Shared signature-help store. Used by every
|
|
/// [`crate::signature::SignatureView`].
|
|
#[must_use]
|
|
pub fn signature_store(&self) -> crate::signature::SharedSignatureStore {
|
|
self.signature_store.clone()
|
|
}
|
|
|
|
/// Shared definition store (T M4.12).
|
|
#[must_use]
|
|
pub fn definition_store(&self) -> crate::definition::SharedDefinitionStore {
|
|
self.definition_store.clone()
|
|
}
|
|
|
|
/// Shared locations store — references / declaration /
|
|
/// typeDefinition / implementation (T M4.5).
|
|
#[must_use]
|
|
pub fn locations_store(&self) -> crate::locations::SharedLocationsStore {
|
|
self.locations_store.clone()
|
|
}
|
|
|
|
/// Shared symbol store — documentSymbol / workspace symbol (T M4.5).
|
|
#[must_use]
|
|
pub fn symbol_store(&self) -> crate::symbol::SharedSymbolStore {
|
|
self.symbol_store.clone()
|
|
}
|
|
|
|
/// Shared documentHighlight store (T M4.5).
|
|
#[must_use]
|
|
pub fn document_highlight_store(
|
|
&self,
|
|
) -> crate::document_highlight::SharedDocumentHighlightStore {
|
|
self.document_highlight_store.clone()
|
|
}
|
|
|
|
/// Shared formatting store (T M4.12).
|
|
#[must_use]
|
|
pub fn formatting_store(&self) -> crate::formatting::SharedFormattingStore {
|
|
self.formatting_store.clone()
|
|
}
|
|
|
|
/// Shared rename / `WorkspaceEdit` store (T M4.5 L2).
|
|
#[must_use]
|
|
pub fn rename_store(&self) -> crate::rename::SharedRenameStore {
|
|
self.rename_store.clone()
|
|
}
|
|
|
|
/// Shared prepareRename store (T M4.5).
|
|
#[must_use]
|
|
pub fn prepare_rename_store(&self) -> crate::prepare_rename::SharedPrepareRenameStore {
|
|
self.prepare_rename_store.clone()
|
|
}
|
|
|
|
/// Shared code-action store (T M4.5 L3).
|
|
#[must_use]
|
|
pub fn code_action_store(&self) -> crate::code_action::SharedCodeActionStore {
|
|
self.code_action_store.clone()
|
|
}
|
|
|
|
/// Shared inlay-hint store (T M4.5).
|
|
#[must_use]
|
|
pub fn inlay_hint_store(&self) -> crate::inlay_hint::SharedInlayHintStore {
|
|
self.inlay_hint_store.clone()
|
|
}
|
|
|
|
/// Shared semantic-token store (T M4.5).
|
|
#[must_use]
|
|
pub fn semantic_token_store(&self) -> crate::semantic_tokens::SharedSemanticTokenStore {
|
|
self.semantic_token_store.clone()
|
|
}
|
|
|
|
/// Styling inputs for `uri`'s semantic tokens: the owning
|
|
/// server's negotiated [`PositionEncoding`] and its
|
|
/// `semanticTokensProvider` legend (if the server advertised
|
|
/// one). `None` when no attached server has tokens for `uri`.
|
|
///
|
|
/// Step 0 of the semantic-frontend producer arc established why
|
|
/// this is needed: `SemanticToken` `start`/`length` are LSP
|
|
/// encoding units (UTF-16 for clangd's default) and — unlike
|
|
/// inlay hints — are *not* byte-rewritten by the absorb path's
|
|
/// `inbound_converted`, because the relative-encoded `data`
|
|
/// array carries no `Position`-shaped object for the structural
|
|
/// walk to find. The producer therefore converts them itself and
|
|
/// needs the per-server encoding plus the legend to name token
|
|
/// types. The server resolved here is the same one
|
|
/// [`crate::semantic_tokens::SemanticTokenStore::for_uri`]
|
|
/// returns (lowest id), so a producer's token read and this
|
|
/// context read agree on the source.
|
|
#[must_use]
|
|
pub fn semantic_style_context(&self, uri: &str) -> Option<SemanticStyleContext> {
|
|
let server_key = {
|
|
let store = self.semantic_token_store.lock().ok()?;
|
|
store.for_uri(uri).map(|(s, _)| s.to_owned())?
|
|
};
|
|
let sid = self
|
|
.clients
|
|
.keys()
|
|
.copied()
|
|
.find(|id| id.raw().to_string() == server_key)?;
|
|
let legend = self
|
|
.capabilities(sid)
|
|
.and_then(crate::semantic_tokens::SemanticTokensLegend::from_capabilities);
|
|
Some(SemanticStyleContext {
|
|
encoding: self.position_encoding(sid),
|
|
legend,
|
|
})
|
|
}
|
|
|
|
/// Test-only: register a synthetic, already-`Initialized` client
|
|
/// (no child process) with the given `initialize` capabilities
|
|
/// and negotiated encoding; returns its id. Lets producer tests
|
|
/// drive the LSP-styling path — where `semantic_style_context`
|
|
/// must resolve a real client's legend + encoding — without a
|
|
/// live server. This is exactly the post-handshake client state
|
|
/// minus the process; nothing here can reach the wire.
|
|
#[cfg(test)]
|
|
pub(crate) fn insert_initialized_test_client(
|
|
&mut self,
|
|
capabilities: Value,
|
|
encoding: PositionEncoding,
|
|
) -> LspServerId {
|
|
let id = LspServerId::next();
|
|
let mut client = LspClient::new(LspServerSpec::new("test", "test", "true"));
|
|
client.state = LspClientState::Initialized {
|
|
capabilities,
|
|
server_info: None,
|
|
initialized_at: Instant::now(),
|
|
};
|
|
client.position_encoding = encoding;
|
|
self.clients.insert(id, client);
|
|
id
|
|
}
|
|
|
|
/// T M4.8: per-server status snapshot, derived from the LSP event
|
|
/// stream. The modeline reads its label from this.
|
|
#[must_use]
|
|
pub fn status_for(&self, sid: LspServerId) -> Option<&crate::lsp_status::LspStatus> {
|
|
self.status_tracker.get(sid)
|
|
}
|
|
|
|
/// T M4.8: short modeline label for `sid`, e.g. `"ready"`,
|
|
/// `"idx"`, `"crashed"`. Falls back to `"?"` for unknown ids.
|
|
#[must_use]
|
|
pub fn modeline_label(&self, sid: LspServerId) -> &'static str {
|
|
self.status_tracker.get(sid).map_or("?", |s| s.kind.label())
|
|
}
|
|
|
|
/// T M4.8: most recent error observed for `sid`, if any.
|
|
#[must_use]
|
|
pub fn last_error(&self, sid: LspServerId) -> Option<&crate::lsp_status::LspStatusError> {
|
|
self.status_tracker
|
|
.get(sid)
|
|
.and_then(|s| s.last_error.as_ref())
|
|
}
|
|
|
|
/// T M4.8: render the contents of the `*lsp*` status buffer.
|
|
#[must_use]
|
|
pub fn status_buffer_text(&self) -> String {
|
|
let now = Instant::now();
|
|
let labels: HashMap<LspServerId, String> = self
|
|
.clients
|
|
.iter()
|
|
.map(|(id, c)| (*id, c.spec.label.clone()))
|
|
.collect();
|
|
let caps: HashMap<LspServerId, Value> = self
|
|
.clients
|
|
.iter()
|
|
.filter_map(|(id, c)| c.capabilities().cloned().map(|v| (*id, v)))
|
|
.collect();
|
|
crate::lsp_status::format_status_buffer(
|
|
&self.status_tracker,
|
|
|id| labels.get(&id).cloned(),
|
|
|id| caps.get(&id).cloned(),
|
|
now,
|
|
)
|
|
}
|
|
|
|
/// Override the restart back-off. Test helper.
|
|
pub fn set_restart_backoff(&mut self, d: Duration) {
|
|
self.restart_backoff = d;
|
|
}
|
|
|
|
/// Spawn a server. Synchronous; the spawn itself is reported
|
|
/// via the next tick's `Started` event (or `Crashed` if the
|
|
/// supervisor refused the exec).
|
|
pub fn spawn(&mut self, spec: LspServerSpec) -> Result<LspServerId, String> {
|
|
let id = LspServerId::next();
|
|
let mut client = LspClient::new(spec);
|
|
self.start_generation(id, &mut client)?;
|
|
self.clients.insert(id, client);
|
|
Ok(id)
|
|
}
|
|
|
|
/// T M4.9: get-or-spawn one LSP server per `(project_root,
|
|
/// language_id)` pair. If a healthy server is already serving
|
|
/// the pair, returns its id; otherwise spawns one using
|
|
/// `spec_template` (with `cwd` defaulted to `project_root` and
|
|
/// `root_uri` defaulted to a `file://` URI) and records the
|
|
/// mapping. Crashed and stopped servers are pruned before lookup
|
|
/// so the caller never gets a dead handle.
|
|
pub fn ensure_server_for_project(
|
|
&mut self,
|
|
project_root: impl Into<PathBuf>,
|
|
language_id: impl Into<String>,
|
|
spec_template: LspServerSpec,
|
|
) -> Result<LspServerId, String> {
|
|
let project_root = canonicalize_root_for_scope(&project_root.into());
|
|
let language_id = language_id.into();
|
|
let key = (project_root.clone(), language_id.clone());
|
|
// Drop the cached id if the server has died or been forgotten.
|
|
if let Some(id) = self.project_servers.get(&key).copied() {
|
|
let still_healthy = self.clients.get(&id).is_some_and(|c| {
|
|
!matches!(
|
|
c.state,
|
|
LspClientState::Stopped { .. } | LspClientState::Crashed { .. }
|
|
)
|
|
});
|
|
if still_healthy {
|
|
return Ok(id);
|
|
}
|
|
self.project_servers.remove(&key);
|
|
}
|
|
// Materialise the spec: default cwd / root_uri from the project root.
|
|
let mut spec = spec_template;
|
|
spec.language_id = language_id;
|
|
if spec.cwd.is_none() {
|
|
spec.cwd = Some(project_root.clone());
|
|
}
|
|
if spec.root_uri.is_none() {
|
|
spec.root_uri = Some(path_to_file_uri(&project_root));
|
|
}
|
|
let id = self.spawn(spec)?;
|
|
self.project_servers.insert(key, id);
|
|
Ok(id)
|
|
}
|
|
|
|
/// T M4.9: lookup-only. Returns the server id currently serving
|
|
/// `(project_root, language_id)` if any.
|
|
#[must_use]
|
|
pub fn server_for_project(
|
|
&self,
|
|
project_root: &Path,
|
|
language_id: &str,
|
|
) -> Option<LspServerId> {
|
|
let canon = canonicalize_root_for_scope(project_root);
|
|
self.project_servers
|
|
.get(&(canon, language_id.to_owned()))
|
|
.copied()
|
|
}
|
|
|
|
/// T M4.9: number of servers scoped to projects.
|
|
#[must_use]
|
|
pub fn project_scoped_server_count(&self) -> usize {
|
|
self.project_servers.len()
|
|
}
|
|
|
|
fn start_generation(&mut self, id: LspServerId, client: &mut LspClient) -> Result<(), String> {
|
|
client.attempt += 1;
|
|
client.next_restart_at = None;
|
|
client.stdout = FrameParser::new();
|
|
client.pending.clear();
|
|
// T M4.5 task #7: the new generation has a fresh request-id
|
|
// space; stale cancelled ids can never collide, so reset to
|
|
// keep the set from growing across restarts.
|
|
client.cancelled_rids.clear();
|
|
// Drop notifications buffered against the dead generation:
|
|
// they reference its (now-gone) document state, and the
|
|
// editor's reattach path issues fresh `did_open`s after the
|
|
// new generation finishes initializing.
|
|
client.deferred_notifications.clear();
|
|
// T M4.7: drop any pending response routes for this server.
|
|
// Their request ids belong to the previous generation; the
|
|
// new server starts request id numbering fresh.
|
|
self.pending_routes.retain(|(sid, _), _| *sid != id);
|
|
// T M4.5: the previous generation's in-flight awaiters will
|
|
// never get a response (new process, fresh id space) — wake
|
|
// them cancelled before the restart.
|
|
self.drain_external_cancelled(id);
|
|
// T M4.5 Option B: drop cached docs; the fresh server gets a
|
|
// new `did_open` from the editor's reattach path.
|
|
self.documents.retain(|(s, _), _| *s != id);
|
|
client.state = LspClientState::Starting;
|
|
let proc_spec = client.spec.to_process_spec();
|
|
let pid = self.supervisor.borrow_mut().spawn(proc_spec)?;
|
|
client.process = Some(pid);
|
|
self.process_to_server.insert(pid, id);
|
|
Ok(())
|
|
}
|
|
|
|
/// Send `shutdown` + `exit` to the server, then forget it. The
|
|
/// supervisor reaps the process; the manager removes the client
|
|
/// once the exit lands. Idempotent: a server that's already
|
|
/// stopped/crashed is removed without further protocol traffic.
|
|
pub fn stop(&mut self, id: LspServerId) -> Result<(), String> {
|
|
let Some(client) = self.clients.get_mut(&id) else {
|
|
return Err(format!("unknown server: {id}"));
|
|
};
|
|
// Disable restart so a clean exit doesn't trigger a respawn.
|
|
client.spec.restart = LspRestartPolicy::Never;
|
|
client.next_restart_at = None;
|
|
if let LspClientState::Initialized { .. } = &client.state {
|
|
let req_id = next_request_id(client);
|
|
let body = make_request(req_id, "shutdown", Value::Null);
|
|
client.pending.insert(req_id, "shutdown".to_owned());
|
|
client.state = LspClientState::ShuttingDown {
|
|
shutdown_request_id: Some(req_id),
|
|
};
|
|
send_frame_to(&self.supervisor, client, &body)?;
|
|
} else {
|
|
// Not yet initialized (or already shutting down /
|
|
// stopped / crashed). Skip the polite path: terminate
|
|
// the process directly.
|
|
if let Some(pid) = client.process {
|
|
let _ = self.supervisor.borrow_mut().terminate(pid);
|
|
}
|
|
// Mark as stopping so the next exit observation cleans up.
|
|
client.state = LspClientState::ShuttingDown {
|
|
shutdown_request_id: None,
|
|
};
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Send a JSON-RPC request to `id`. Returns the JSON-RPC id;
|
|
/// the response will surface as an [`LspEventKind::Response`]
|
|
/// event tagged with the same id.
|
|
pub fn send_request(
|
|
&mut self,
|
|
id: LspServerId,
|
|
method: impl Into<String>,
|
|
params: Value,
|
|
) -> Result<u64, String> {
|
|
let method = method.into();
|
|
let client = self
|
|
.clients
|
|
.get_mut(&id)
|
|
.ok_or_else(|| format!("unknown server: {id}"))?;
|
|
if !is_ready_for_request(&client.state) {
|
|
return Err(format!(
|
|
"server {id} is not ready for requests (state: {})",
|
|
state_label(&client.state)
|
|
));
|
|
}
|
|
let req_id = next_request_id(client);
|
|
let body = make_request(req_id, &method, params);
|
|
client.pending.insert(req_id, method);
|
|
send_frame_to(&self.supervisor, client, &body)?;
|
|
Ok(req_id)
|
|
}
|
|
|
|
/// Send a JSON-RPC notification to `id`. Fire-and-forget; no
|
|
/// response is expected.
|
|
pub fn send_notification(
|
|
&mut self,
|
|
id: LspServerId,
|
|
method: impl Into<String>,
|
|
params: Value,
|
|
) -> Result<(), String> {
|
|
let method = method.into();
|
|
let client = self
|
|
.clients
|
|
.get_mut(&id)
|
|
.ok_or_else(|| format!("unknown server: {id}"))?;
|
|
// Disallowed after Stopped/Crashed since stdin is gone.
|
|
if matches!(
|
|
client.state,
|
|
LspClientState::Stopped { .. } | LspClientState::Crashed { .. }
|
|
) {
|
|
return Err(format!(
|
|
"server {id} is not running (state: {})",
|
|
state_label(&client.state)
|
|
));
|
|
}
|
|
// Before the `initialize` / `initialized` handshake completes
|
|
// the only notification the spec permits is `initialized`
|
|
// itself — and that one is sent directly by `handle_response`,
|
|
// never through here. Everything else issued this early
|
|
// (`textDocument/didOpen` from the editor's attach hook is the
|
|
// common one) is buffered and replayed in order once the
|
|
// server reaches `Initialized`; sending it now would be
|
|
// discarded by a strict server, breaking every later request
|
|
// with "non-added document".
|
|
if matches!(
|
|
client.state,
|
|
LspClientState::Starting | LspClientState::Initializing { .. }
|
|
) {
|
|
client.deferred_notifications.push((method, params));
|
|
return Ok(());
|
|
}
|
|
let body = make_notification(&method, params);
|
|
send_frame_to(&self.supervisor, client, &body)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Replay, in issue order, the notifications buffered by
|
|
/// [`Self::send_notification`] while `sid` was still initializing.
|
|
/// Called once, from the `initialize`-response handler, right
|
|
/// after the `initialized` notification is sent.
|
|
fn flush_deferred_notifications(&mut self, sid: LspServerId) {
|
|
let deferred = self
|
|
.clients
|
|
.get_mut(&sid)
|
|
.map(|c| std::mem::take(&mut c.deferred_notifications))
|
|
.unwrap_or_default();
|
|
if !deferred.is_empty()
|
|
&& let Some(client) = self.clients.get(&sid)
|
|
{
|
|
for (method, params) in deferred {
|
|
let body = make_notification(&method, params);
|
|
let _ = send_frame_to(&self.supervisor, client, &body);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The position encoding `sid` negotiated, or the spec default
|
|
/// (UTF-16) if the server is unknown / not yet initialized.
|
|
fn position_encoding(&self, sid: LspServerId) -> PositionEncoding {
|
|
self.clients
|
|
.get(&sid)
|
|
.map_or(PositionEncoding::default(), |c| c.position_encoding)
|
|
}
|
|
|
|
/// Outbound: build the LSP `Position` for `(line, byte_col)`,
|
|
/// converting the pmacs byte offset into the server's negotiated
|
|
/// `character` units. If the document is not cached (no `did_open`
|
|
/// seen yet) the byte offset is sent as-is — correct for ASCII /
|
|
/// the UTF-8 fast path and the same behaviour pmacs had before
|
|
/// Option B, so the fallback never regresses the common case.
|
|
fn outbound_position(&self, sid: LspServerId, uri: &str, line: u32, byte_col: u32) -> Value {
|
|
let enc = self.position_encoding(sid);
|
|
let character = match self
|
|
.documents
|
|
.get(&(sid, uri.to_owned()))
|
|
.and_then(|doc| nth_line(doc, line))
|
|
{
|
|
Some(line_text) => byte_to_char(line_text, byte_col as usize, enc),
|
|
// No cached doc, or cursor line not in it ⇒ send the byte
|
|
// offset as-is (correct for ASCII / the UTF-8 path and the
|
|
// pre-Option-B behaviour).
|
|
None => byte_col,
|
|
};
|
|
json!({ "line": line, "character": character })
|
|
}
|
|
|
|
/// Inbound: clone `value` and rewrite every nested `Position` so
|
|
/// `character` is a pmacs byte offset. No cached document (or the
|
|
/// UTF-8 fast path) ⇒ returned unchanged.
|
|
fn inbound_converted(&self, sid: LspServerId, uri: &str, value: &Value) -> Value {
|
|
let enc = self.position_encoding(sid);
|
|
let mut owned = value.clone();
|
|
if let Some(doc) = self.documents.get(&(sid, uri.to_owned())) {
|
|
rewrite_positions_to_bytes(&mut owned, doc, enc);
|
|
}
|
|
owned
|
|
}
|
|
|
|
/// Register an async-runtime awaiter for the just-sent request
|
|
/// `req_id` and return its [`JobId`] — the value the Lua surface
|
|
/// wraps in a `pmacs.workers` Handle. Called only after
|
|
/// [`Self::send_request`] succeeded, so the wire request is live
|
|
/// and a response (or a teardown drain) will settle it; no
|
|
/// rollback path is needed here (unlike `mcp.rs`, where the
|
|
/// register precedes the wire send). T M4.5 async bridge.
|
|
///
|
|
/// T M4.5 task #7: the awaiter is registered with a supersede key
|
|
/// `lsp:{method}:{sid}:{uri}` — stable across calls for the same
|
|
/// (server, method, document). A newer request for the same thing
|
|
/// flips the prior job's [`CancellationToken`] through the async
|
|
/// runtime's supersede map; the next [`Self::drain_cancelled_externals`]
|
|
/// sweep settles that awaiter cancelled and `$/cancelRequest`s the
|
|
/// in-flight wire request. This is what keeps keystroke-driven
|
|
/// completion from piling up N in-flight requests on the server.
|
|
fn register_awaiter(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
req_id: u64,
|
|
method: &str,
|
|
uri: &str,
|
|
) -> JobId {
|
|
let supersede = format!("lsp:{method}:{}:{uri}", sid.raw());
|
|
let (job_id, token) = self
|
|
.runtime
|
|
.register_external(JobKind::LspRequest, Some(&supersede));
|
|
self.pending_external.insert(
|
|
(sid, req_id),
|
|
PendingExternal {
|
|
method: method.to_owned(),
|
|
awaiters: vec![Awaiter { job_id, token }],
|
|
dispatched_at: Instant::now(),
|
|
},
|
|
);
|
|
job_id
|
|
}
|
|
|
|
/// T M4.5 task #8: override the per-request timeout (default 10s).
|
|
/// Exposed to Lua as `pmacs.lsp.set_request_timeout_ms` and used
|
|
/// by the await-path tests to force fast timeouts.
|
|
pub fn set_request_timeout(&mut self, timeout: Duration) {
|
|
self.request_timeout = timeout;
|
|
}
|
|
|
|
/// T M4.5 async bridge: settle every awaiter for `sid` as
|
|
/// cancelled and drop its `pending_external` entries. Called
|
|
/// wherever [`Self::pending_routes`] is purged for a server
|
|
/// (restart generation flip / terminal exit / forget) so a
|
|
/// coroutine parked on a request whose server went away wakes
|
|
/// with `{ tag = "cancelled" }` instead of hanging forever.
|
|
/// Mirrors `mcp.rs`'s drain-on-exit. Idempotent: a second call
|
|
/// finds no entries (and `complete_external_cancelled` is itself
|
|
/// idempotent against double-completion).
|
|
fn drain_external_cancelled(&mut self, sid: LspServerId) {
|
|
let keys: Vec<(LspServerId, u64)> = self
|
|
.pending_external
|
|
.keys()
|
|
.filter(|(s, _)| *s == sid)
|
|
.copied()
|
|
.collect();
|
|
for k in keys {
|
|
if let Some(p) = self.pending_external.remove(&k) {
|
|
for a in &p.awaiters {
|
|
self.runtime.complete_external_cancelled(a.job_id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// T M4.5 task #7: per-tick per-awaiter cancellation sweep for
|
|
/// `sid`. An awaiter whose [`CancellationToken`] was flipped —
|
|
/// either by `Handle:cancel()` or by being superseded by a newer
|
|
/// same-key request (see [`Self::register_awaiter`]) — is removed
|
|
/// and settled cancelled; the in-flight wire request continues if
|
|
/// sibling awaiters still want it. When the *last* awaiter of a
|
|
/// request abandons it, the entry and its route are dropped, the
|
|
/// rid is recorded in `cancelled_rids` so a late response is
|
|
/// dropped silently, and `$/cancelRequest` is sent best-effort so
|
|
/// the server can stop working. Mirrors `mcp.rs`'s
|
|
/// `drain_cancelled_externals` (LSP has no resource cache, so the
|
|
/// cache-state plumbing is omitted).
|
|
///
|
|
/// T M4.5 task #8: the same sweep also fails any awaiter whose
|
|
/// request has outlived [`Self::request_timeout`] — a server that
|
|
/// stays alive but never answers a particular id would otherwise
|
|
/// park its coroutine forever. Timed-out entries take the same
|
|
/// abandon path as fully-cancelled ones (`$/cancelRequest` +
|
|
/// `cancelled_rids`), but settle `failed` rather than `cancelled`.
|
|
fn drain_cancelled_externals(&mut self, sid: LspServerId) {
|
|
let mut cancelled_jobs: Vec<JobId> = Vec::new();
|
|
let mut timed_out_jobs: Vec<(JobId, String)> = Vec::new();
|
|
let mut abandoned_rids: Vec<u64> = Vec::new();
|
|
// Captured into locals so the `retain` closure doesn't have to
|
|
// borrow `self` (it already borrows `self.pending_external`).
|
|
let now = Instant::now();
|
|
let timeout = self.request_timeout;
|
|
let timeout_ms = timeout.as_millis();
|
|
self.pending_external.retain(|(s, rid), p| {
|
|
if *s != sid {
|
|
return true;
|
|
}
|
|
let mut still: Vec<Awaiter> = Vec::with_capacity(p.awaiters.len());
|
|
for a in p.awaiters.drain(..) {
|
|
if a.token.is_cancelled() {
|
|
cancelled_jobs.push(a.job_id);
|
|
} else {
|
|
still.push(a);
|
|
}
|
|
}
|
|
// T M4.5 task #8: any awaiter that survived the cancel
|
|
// pass but whose request has outlived the timeout fails
|
|
// now — the server is alive but not answering this id.
|
|
if !still.is_empty() && now.duration_since(p.dispatched_at) >= timeout {
|
|
for a in still.drain(..) {
|
|
timed_out_jobs.push((a.job_id, p.method.clone()));
|
|
}
|
|
}
|
|
p.awaiters = still;
|
|
if p.awaiters.is_empty() {
|
|
abandoned_rids.push(*rid);
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
});
|
|
for job_id in cancelled_jobs {
|
|
self.runtime.complete_external_cancelled(job_id);
|
|
}
|
|
for (job_id, method) in timed_out_jobs {
|
|
self.runtime.complete_external_failed(
|
|
job_id,
|
|
format!("LSP {method}: request timed out after {timeout_ms}ms"),
|
|
);
|
|
}
|
|
for rid in abandoned_rids {
|
|
self.pending_routes.remove(&(sid, rid));
|
|
if let Some(client) = self.clients.get_mut(&sid) {
|
|
client.pending.remove(&rid);
|
|
client.cancelled_rids.insert(rid);
|
|
}
|
|
self.send_cancel_request(sid, rid);
|
|
}
|
|
}
|
|
|
|
/// Send `$/cancelRequest { id }` to `sid`, best-effort. A server
|
|
/// that is not accepting writes (stopped / crashed) is skipped by
|
|
/// [`Self::send_notification`]'s state guard; the `Err` is
|
|
/// intentionally ignored. T M4.5 task #7.
|
|
fn send_cancel_request(&mut self, sid: LspServerId, rid: u64) {
|
|
let _ = self.send_notification(sid, "$/cancelRequest", json!({ "id": rid }));
|
|
}
|
|
|
|
/// Send `textDocument/completion` for `uri` at `(line, col)`.
|
|
/// The response is absorbed into the completion store at
|
|
/// `(sid, uri)` (popup consumers untouched) and also settles the
|
|
/// returned awaiter. The same `LspEventKind::Response` event is
|
|
/// still emitted for raw observers. Returns the async-runtime
|
|
/// [`JobId`] the response will settle (`type JobId = u64`, so the
|
|
/// signature is unchanged for existing Rust callers — only the
|
|
/// value's meaning moved from JSON-RPC id to job id, and no
|
|
/// caller consumes it). T M4.5 async bridge.
|
|
pub fn request_completion(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
line: u32,
|
|
col: u32,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"position": self.outbound_position(sid, &uri, line, col)
|
|
});
|
|
let req_id = self.send_request(sid, "textDocument/completion", params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/completion", &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::Completion { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/hover` for `uri` at `(line, col)`. Returns
|
|
/// the async-runtime [`JobId`] the response will settle.
|
|
pub fn request_hover(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
line: u32,
|
|
col: u32,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"position": self.outbound_position(sid, &uri, line, col)
|
|
});
|
|
let req_id = self.send_request(sid, "textDocument/hover", params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/hover", &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::Hover { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/signatureHelp` for `uri` at `(line, col)`.
|
|
/// Returns the async-runtime [`JobId`] the response will settle.
|
|
pub fn request_signature_help(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
line: u32,
|
|
col: u32,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"position": self.outbound_position(sid, &uri, line, col)
|
|
});
|
|
let req_id = self.send_request(sid, "textDocument/signatureHelp", params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/signatureHelp", &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::Signature { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/definition` for `uri` at `(line, col)`. The
|
|
/// response is absorbed into the definition store at `(sid, uri)`.
|
|
/// Returns the async-runtime [`JobId`] the response will settle.
|
|
pub fn request_definition(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
line: u32,
|
|
col: u32,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"position": self.outbound_position(sid, &uri, line, col)
|
|
});
|
|
let req_id = self.send_request(sid, "textDocument/definition", params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/definition", &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::Definition { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Shared body for the Location-shaped nav requests. `extra` is
|
|
/// merged into the params object (only `references` uses it, for
|
|
/// `context.includeDeclaration`). The supersede key derives from
|
|
/// the kind's distinct method, so the four requests don't cancel
|
|
/// one another. Returns the async-runtime [`JobId`].
|
|
fn request_locations(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
line: u32,
|
|
col: u32,
|
|
kind: crate::locations::LocationKind,
|
|
extra: Option<Value>,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let mut params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"position": self.outbound_position(sid, &uri, line, col)
|
|
});
|
|
if let Some(Value::Object(ex)) = extra
|
|
&& let Some(obj) = params.as_object_mut()
|
|
{
|
|
for (k, v) in ex {
|
|
obj.insert(k, v);
|
|
}
|
|
}
|
|
let method = kind.method();
|
|
let req_id = self.send_request(sid, method, params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, method, &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::Locations { uri, kind });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/references` (with `includeDeclaration`).
|
|
/// Returns the async-runtime [`JobId`].
|
|
pub fn request_references(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
line: u32,
|
|
col: u32,
|
|
) -> Result<JobId, String> {
|
|
self.request_locations(
|
|
sid,
|
|
uri,
|
|
line,
|
|
col,
|
|
crate::locations::LocationKind::References,
|
|
Some(json!({ "context": { "includeDeclaration": true } })),
|
|
)
|
|
}
|
|
|
|
/// Send `textDocument/declaration`. Returns the [`JobId`].
|
|
pub fn request_declaration(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
line: u32,
|
|
col: u32,
|
|
) -> Result<JobId, String> {
|
|
self.request_locations(
|
|
sid,
|
|
uri,
|
|
line,
|
|
col,
|
|
crate::locations::LocationKind::Declaration,
|
|
None,
|
|
)
|
|
}
|
|
|
|
/// Send `textDocument/typeDefinition`. Returns the [`JobId`].
|
|
pub fn request_type_definition(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
line: u32,
|
|
col: u32,
|
|
) -> Result<JobId, String> {
|
|
self.request_locations(
|
|
sid,
|
|
uri,
|
|
line,
|
|
col,
|
|
crate::locations::LocationKind::TypeDefinition,
|
|
None,
|
|
)
|
|
}
|
|
|
|
/// Send `textDocument/implementation`. Returns the [`JobId`].
|
|
pub fn request_implementation(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
line: u32,
|
|
col: u32,
|
|
) -> Result<JobId, String> {
|
|
self.request_locations(
|
|
sid,
|
|
uri,
|
|
line,
|
|
col,
|
|
crate::locations::LocationKind::Implementation,
|
|
None,
|
|
)
|
|
}
|
|
|
|
/// Send `textDocument/documentSymbol` (no position — whole doc).
|
|
/// Returns the async-runtime [`JobId`].
|
|
pub fn request_document_symbol(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let params = json!({ "textDocument": { "uri": uri.clone() } });
|
|
let method = "textDocument/documentSymbol";
|
|
let req_id = self.send_request(sid, method, params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, method, &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::DocumentSymbol { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `workspace/symbol` for `query`. Returns the [`JobId`].
|
|
pub fn request_workspace_symbol(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
query: impl Into<String>,
|
|
) -> Result<JobId, String> {
|
|
let query = query.into();
|
|
let params = json!({ "query": query.clone() });
|
|
let method = "workspace/symbol";
|
|
let req_id = self.send_request(sid, method, params)?;
|
|
// The query stands in for the doc URI in the supersede key:
|
|
// a newer query for the same string supersedes the prior.
|
|
let job_id = self.register_awaiter(sid, req_id, method, &query);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::WorkspaceSymbol { query });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/documentHighlight` at `(line, col)`.
|
|
/// Returns the [`JobId`].
|
|
pub fn request_document_highlight(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
line: u32,
|
|
col: u32,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"position": self.outbound_position(sid, &uri, line, col)
|
|
});
|
|
let method = "textDocument/documentHighlight";
|
|
let req_id = self.send_request(sid, method, params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, method, &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::DocumentHighlight { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/formatting` for `uri` with `tab_size` /
|
|
/// `insert_spaces` formatting options. The response is absorbed
|
|
/// into the formatting store at `(sid, uri)`. Returns the
|
|
/// async-runtime [`JobId`] the response will settle.
|
|
pub fn request_formatting(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
tab_size: u32,
|
|
insert_spaces: bool,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"options": {
|
|
"tabSize": tab_size,
|
|
"insertSpaces": insert_spaces,
|
|
}
|
|
});
|
|
let req_id = self.send_request(sid, "textDocument/formatting", params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/formatting", &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::Formatting { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/rename` for the symbol at `(line, col)` in
|
|
/// `uri`, requesting `new_name`. The response is a `WorkspaceEdit`
|
|
/// (possibly multi-file); it is absorbed into the rename store
|
|
/// keyed by the *origin* `uri`. Returns the async-runtime
|
|
/// [`JobId`] the response will settle.
|
|
pub fn request_rename(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
line: u32,
|
|
col: u32,
|
|
new_name: impl Into<String>,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"position": self.outbound_position(sid, &uri, line, col),
|
|
"newName": new_name.into(),
|
|
});
|
|
let req_id = self.send_request(sid, "textDocument/rename", params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/rename", &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::Rename { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/prepareRename` for the symbol at `(line,
|
|
/// col)` in `uri`. The response (renameable? + extent +
|
|
/// placeholder) is absorbed into the prepareRename store at
|
|
/// `(sid, uri)`. Returns the async-runtime [`JobId`] the response
|
|
/// will settle.
|
|
pub fn request_prepare_rename(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
line: u32,
|
|
col: u32,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"position": self.outbound_position(sid, &uri, line, col),
|
|
});
|
|
let req_id = self.send_request(sid, "textDocument/prepareRename", params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/prepareRename", &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::PrepareRename { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/codeAction` over `[start, end]` in `uri`.
|
|
/// `context.diagnostics` is passed through verbatim (callers feed
|
|
/// the overlapping diagnostics so quick-fixes resolve); an empty
|
|
/// slice is a valid "no diagnostics" context. The response is
|
|
/// absorbed into the code-action store at `(sid, uri)`. Returns
|
|
/// the async-runtime [`JobId`] the response will settle.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn request_code_action(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
start_line: u32,
|
|
start_col: u32,
|
|
end_line: u32,
|
|
end_col: u32,
|
|
diagnostics: &[Value],
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
// Same encoding rule as every outbound range: columns are pmacs
|
|
// byte offsets and must convert to the negotiated units — a
|
|
// UTF-16 server receiving byte columns resolves the wrong range
|
|
// for non-ASCII lines (same bug class as the semantic-token
|
|
// range fix in this change).
|
|
let params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"range": {
|
|
"start": self.outbound_position(sid, &uri, start_line, start_col),
|
|
"end": self.outbound_position(sid, &uri, end_line, end_col),
|
|
},
|
|
"context": { "diagnostics": diagnostics },
|
|
});
|
|
let req_id = self.send_request(sid, "textDocument/codeAction", params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/codeAction", &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::CodeAction { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/inlayHint` over `[start, end]` in `uri`
|
|
/// (the visible/whole-buffer range the caller wants annotated).
|
|
/// The response is absorbed into the inlay-hint store at `(sid,
|
|
/// uri)`. Returns the async-runtime [`JobId`] the response will
|
|
/// settle.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn request_inlay_hint(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
start_line: u32,
|
|
start_col: u32,
|
|
end_line: u32,
|
|
end_col: u32,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"range": {
|
|
"start": self.outbound_position(sid, &uri, start_line, start_col),
|
|
"end": self.outbound_position(sid, &uri, end_line, end_col),
|
|
},
|
|
});
|
|
let req_id = self.send_request(sid, "textDocument/inlayHint", params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/inlayHint", &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::InlayHint { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/semanticTokens/full` for `uri`. The response
|
|
/// (the relative-encoded `data` array) is decoded and absorbed
|
|
/// into the semantic-token store at `(sid, uri)`. Returns the
|
|
/// async-runtime [`JobId`] the response will settle.
|
|
pub fn request_semantic_tokens(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let params = json!({ "textDocument": { "uri": uri.clone() } });
|
|
let req_id = self.send_request(sid, "textDocument/semanticTokens/full", params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/semanticTokens/full", &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::SemanticTokens { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/semanticTokens/range` for the `[start, end]`
|
|
/// slice of `uri` (the visible viewport, for large files). The
|
|
/// response shape is identical to `/full` and shares the same
|
|
/// store entry / route.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn request_semantic_tokens_range(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
start_line: u32,
|
|
start_col: u32,
|
|
end_line: u32,
|
|
end_col: u32,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
// Columns arrive as pmacs byte offsets; convert per the
|
|
// negotiated position encoding, exactly like the inlay-hint
|
|
// range. A UTF-16 server receiving raw byte columns gets an
|
|
// invalid end character for non-ASCII text ("é" is two bytes
|
|
// but one UTF-16 unit) and may reject the request — fatal for
|
|
// a range-only provider whose ONLY pull path this is.
|
|
let params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"range": {
|
|
"start": self.outbound_position(sid, &uri, start_line, start_col),
|
|
"end": self.outbound_position(sid, &uri, end_line, end_col),
|
|
},
|
|
});
|
|
let req_id = self.send_request(sid, "textDocument/semanticTokens/range", params)?;
|
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/semanticTokens/range", &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::SemanticTokens { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `textDocument/semanticTokens/full/delta` for `uri`,
|
|
/// passing the `previous_result_id` the last full/delta response
|
|
/// carried. The response (a `SemanticTokensDelta`, or a full
|
|
/// `SemanticTokens` if the server declined a delta) is spliced
|
|
/// against the store's retained raw int stream.
|
|
pub fn request_semantic_tokens_delta(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
previous_result_id: impl Into<String>,
|
|
) -> Result<JobId, String> {
|
|
let uri = uri.into();
|
|
let params = json!({
|
|
"textDocument": { "uri": uri.clone() },
|
|
"previousResultId": previous_result_id.into(),
|
|
});
|
|
let req_id = self.send_request(sid, "textDocument/semanticTokens/full/delta", params)?;
|
|
let job_id =
|
|
self.register_awaiter(sid, req_id, "textDocument/semanticTokens/full/delta", &uri);
|
|
self.pending_routes
|
|
.insert((sid, req_id), ResponseRoute::SemanticTokensDelta { uri });
|
|
Ok(job_id)
|
|
}
|
|
|
|
/// Send `workspace/executeCommand`. No response route is
|
|
/// registered: the command result is usually `null` and the real
|
|
/// effect arrives as a server→client `workspace/applyEdit` (the
|
|
/// Lua event pump applies it). The returned awaiter still settles
|
|
/// when the command response lands so callers can sequence work
|
|
/// after it. Returns the async-runtime [`JobId`].
|
|
pub fn request_execute_command(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
command: impl Into<String>,
|
|
arguments: &[Value],
|
|
) -> Result<JobId, String> {
|
|
let command = command.into();
|
|
let params = json!({ "command": command, "arguments": arguments });
|
|
let req_id = self.send_request(sid, "workspace/executeCommand", params)?;
|
|
// Awaiter only — `absorb_routed_response` has no CodeAction-
|
|
// result store and the effect is delivered out of band.
|
|
Ok(self.register_awaiter(sid, req_id, "workspace/executeCommand", &command))
|
|
}
|
|
|
|
/// Reply to a server-initiated request.
|
|
pub fn send_response(
|
|
&mut self,
|
|
id: LspServerId,
|
|
request_id: Value,
|
|
result: Result<Value, LspError>,
|
|
) -> Result<(), String> {
|
|
let client = self
|
|
.clients
|
|
.get_mut(&id)
|
|
.ok_or_else(|| format!("unknown server: {id}"))?;
|
|
let body = make_response(request_id, result);
|
|
send_frame_to(&self.supervisor, client, &body)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// One pass: drain process events into per-server frame
|
|
/// buffers, parse frames, dispatch to per-server state. Apply
|
|
/// restart policy on terminations.
|
|
///
|
|
/// The supervisor's own `tick` must have already been called
|
|
/// (or be called shortly before/after). [`crate::editor::EditorState::tick_lsp`]
|
|
/// orders both.
|
|
pub fn tick(&mut self) {
|
|
// Snapshot ids first to avoid mutating the map during iter.
|
|
// Frame parsing happens inline inside `drain_process_events`
|
|
// (per Stdout event) so that state transitions from inbound
|
|
// messages always order *before* subsequent Exit events in
|
|
// the same supervisor batch.
|
|
let server_ids: Vec<LspServerId> = self.clients.keys().copied().collect();
|
|
for sid in server_ids {
|
|
self.drain_process_events(sid);
|
|
// T M4.5 task #7: after this tick's responses are absorbed
|
|
// (above), reap any awaiter cancelled or superseded since
|
|
// the last tick.
|
|
self.drain_cancelled_externals(sid);
|
|
self.maybe_restart(sid);
|
|
}
|
|
// T M4.8: housekeeping for the status tracker (releases stale
|
|
// `Degraded` flags). Snapshot states first so the closure
|
|
// doesn't have to borrow self.
|
|
let now = Instant::now();
|
|
let states: HashMap<LspServerId, LspClientState> = self
|
|
.clients
|
|
.iter()
|
|
.map(|(id, c)| (*id, c.state.clone()))
|
|
.collect();
|
|
self.status_tracker.tick(now, |id| states.get(&id).cloned());
|
|
}
|
|
|
|
/// Pull supervisor events for the server's process and feed
|
|
/// stdout into the frame parser. Translates exit/signal/crash
|
|
/// into LSP-layer state transitions.
|
|
fn drain_process_events(&mut self, sid: LspServerId) {
|
|
let Some(client) = self.clients.get_mut(&sid) else {
|
|
return;
|
|
};
|
|
let Some(pid) = client.process else {
|
|
return;
|
|
};
|
|
let mut sup = self.supervisor.borrow_mut();
|
|
let events = sup.take_events(pid);
|
|
drop(sup);
|
|
for ev in events {
|
|
self.handle_process_event(sid, ev);
|
|
}
|
|
}
|
|
|
|
fn handle_process_event(&mut self, sid: LspServerId, ev: ProcessEvent) {
|
|
match ev.kind {
|
|
ProcessEventKind::Started { pid } => self.on_started(sid, pid, ev.at),
|
|
ProcessEventKind::Stdout(bytes) => {
|
|
// Buffer and parse inline so any state transitions
|
|
// triggered by complete frames in this chunk happen
|
|
// *before* any subsequent termination event in the
|
|
// same supervisor batch. Without this ordering, an
|
|
// `initialize` response that arrived just before
|
|
// the server crashed would re-enter `Initialized`
|
|
// after the `Exited` had already moved the client
|
|
// to `Crashed`.
|
|
if let Some(client) = self.clients.get_mut(&sid) {
|
|
client.stdout.extend(&bytes);
|
|
}
|
|
self.parse_frames(sid);
|
|
}
|
|
ProcessEventKind::Stderr(bytes) => {
|
|
self.push_event(sid, ev.at, LspEventKind::Stderr(bytes));
|
|
}
|
|
ProcessEventKind::Ansi(_) => {
|
|
self.push_event(
|
|
sid,
|
|
ev.at,
|
|
LspEventKind::ProtocolError {
|
|
message: "supervisor emitted ANSI events for pipe-mode LSP process".into(),
|
|
},
|
|
);
|
|
}
|
|
ProcessEventKind::Exited { code } => {
|
|
self.on_exit(sid, ev.at, format!("exit code {code}"), code == 0);
|
|
}
|
|
ProcessEventKind::Signaled { signal } => {
|
|
self.on_exit(sid, ev.at, format!("signal {signal}"), false);
|
|
}
|
|
ProcessEventKind::Crashed { error } => {
|
|
self.on_exit(sid, ev.at, format!("crashed: {error}"), false);
|
|
}
|
|
ProcessEventKind::Restarting { .. } => {
|
|
// The supervisor's own restart accounting is unused
|
|
// here (we run the LSP layer with RestartPolicy::Never
|
|
// on the supervisor side). If a Restarting event ever
|
|
// arrives, log it as a protocol-layer surprise.
|
|
self.push_event(
|
|
sid,
|
|
ev.at,
|
|
LspEventKind::ProtocolError {
|
|
message: "supervisor emitted Restarting under LSP-managed process".into(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_started(&mut self, sid: LspServerId, pid: u32, at: Instant) {
|
|
let init_request_id = {
|
|
let Some(client) = self.clients.get_mut(&sid) else {
|
|
return;
|
|
};
|
|
let req_id = next_request_id(client);
|
|
client.state = LspClientState::Initializing {
|
|
init_request_id: req_id,
|
|
started: at,
|
|
};
|
|
client.pending.insert(req_id, "initialize".to_owned());
|
|
req_id
|
|
};
|
|
// Build the initialize request payload.
|
|
let body = self.build_initialize(sid, init_request_id);
|
|
if let Some(client) = self.clients.get(&sid)
|
|
&& let Err(e) = send_frame_to(&self.supervisor, client, &body)
|
|
{
|
|
self.push_event(
|
|
sid,
|
|
at,
|
|
LspEventKind::ProtocolError {
|
|
message: format!("failed to send initialize: {e}"),
|
|
},
|
|
);
|
|
}
|
|
self.push_event(sid, at, LspEventKind::Started { pid });
|
|
}
|
|
|
|
fn build_initialize(&self, sid: LspServerId, request_id: u64) -> Vec<u8> {
|
|
let client = self.clients.get(&sid).expect("sid has client");
|
|
let cwd_path: Option<PathBuf> = client
|
|
.spec
|
|
.cwd
|
|
.clone()
|
|
.or_else(|| std::env::current_dir().ok());
|
|
let root_uri = client
|
|
.spec
|
|
.root_uri
|
|
.clone()
|
|
.or_else(|| cwd_path.as_deref().map(path_to_file_uri));
|
|
let process_id_value: Value = std::process::id().into();
|
|
let capabilities = client
|
|
.spec
|
|
.capabilities
|
|
.clone()
|
|
.unwrap_or_else(default_capabilities);
|
|
let mut params = Map::new();
|
|
params.insert("processId".into(), process_id_value);
|
|
if let Some(uri) = root_uri.as_ref() {
|
|
params.insert("rootUri".into(), Value::String(uri.clone()));
|
|
// rootPath is deprecated but still honored by some servers.
|
|
if let Some(p) = cwd_path.as_ref() {
|
|
params.insert("rootPath".into(), Value::String(p.display().to_string()));
|
|
}
|
|
// workspaceFolders so multi-root-aware servers are happy.
|
|
params.insert(
|
|
"workspaceFolders".into(),
|
|
Value::Array(vec![json!({
|
|
"uri": uri,
|
|
"name": client.spec.label.clone(),
|
|
})]),
|
|
);
|
|
} else {
|
|
params.insert("rootUri".into(), Value::Null);
|
|
}
|
|
if let Some(opts) = client.spec.init_options.clone() {
|
|
params.insert("initializationOptions".into(), opts);
|
|
}
|
|
params.insert("capabilities".into(), capabilities);
|
|
params.insert(
|
|
"clientInfo".into(),
|
|
json!({
|
|
"name": "pmacs",
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
}),
|
|
);
|
|
params.insert("trace".into(), Value::String("off".into()));
|
|
make_request(request_id, "initialize", Value::Object(params))
|
|
}
|
|
|
|
fn on_exit(&mut self, sid: LspServerId, at: Instant, reason: String, _clean: bool) {
|
|
let (was_shutdown, restart) = {
|
|
let Some(client) = self.clients.get_mut(&sid) else {
|
|
return;
|
|
};
|
|
let was_shutdown = matches!(client.state, LspClientState::ShuttingDown { .. });
|
|
if was_shutdown {
|
|
client.state = LspClientState::Stopped { ended: at };
|
|
} else {
|
|
client.state = LspClientState::Crashed {
|
|
reason: reason.clone(),
|
|
ended: at,
|
|
};
|
|
}
|
|
// Drop the supervisor↔server mapping; the supervisor is
|
|
// free to forget the pid now.
|
|
if let Some(pid) = client.process.take() {
|
|
self.process_to_server.remove(&pid);
|
|
let _ = self.supervisor.borrow_mut().forget(pid);
|
|
}
|
|
(
|
|
was_shutdown,
|
|
!was_shutdown && should_restart(client.spec.restart),
|
|
)
|
|
};
|
|
// T M4.5: the server is gone (clean stop or crash) and will
|
|
// never answer its in-flight requests. Wake every awaiter
|
|
// cancelled now — not at the eventual restart/forget, which
|
|
// may never come under `LspRestartPolicy::Never`.
|
|
self.drain_external_cancelled(sid);
|
|
self.documents.retain(|(s, _), _| *s != sid);
|
|
if was_shutdown {
|
|
self.push_event(sid, at, LspEventKind::Stopped);
|
|
} else {
|
|
self.push_event(sid, at, LspEventKind::Crashed { reason });
|
|
}
|
|
if restart && let Some(client) = self.clients.get_mut(&sid) {
|
|
client.next_restart_at = Some(at + self.restart_backoff);
|
|
}
|
|
}
|
|
|
|
fn parse_frames(&mut self, sid: LspServerId) {
|
|
loop {
|
|
let frame_or_err = {
|
|
let Some(client) = self.clients.get_mut(&sid) else {
|
|
return;
|
|
};
|
|
client.stdout.next_frame()
|
|
};
|
|
match frame_or_err {
|
|
Ok(Some(body)) => self.dispatch_inbound(sid, &body),
|
|
Ok(None) => return,
|
|
Err(e) => {
|
|
let now = Instant::now();
|
|
self.push_event(
|
|
sid,
|
|
now,
|
|
LspEventKind::ProtocolError {
|
|
message: format!("frame error: {e}; restarting server"),
|
|
},
|
|
);
|
|
// Frame violations are unrecoverable on the same
|
|
// byte stream; terminate and let the restart
|
|
// policy bring things back if configured.
|
|
if let Some(client) = self.clients.get_mut(&sid)
|
|
&& let Some(pid) = client.process
|
|
{
|
|
let _ = self.supervisor.borrow_mut().terminate(pid);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn dispatch_inbound(&mut self, sid: LspServerId, body: &[u8]) {
|
|
let now = Instant::now();
|
|
let value: Value = match serde_json::from_slice(body) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
self.push_event(
|
|
sid,
|
|
now,
|
|
LspEventKind::ProtocolError {
|
|
message: format!("invalid JSON in frame: {e}"),
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let Value::Object(map) = value else {
|
|
self.push_event(
|
|
sid,
|
|
now,
|
|
LspEventKind::ProtocolError {
|
|
message: "frame body was not a JSON object".into(),
|
|
},
|
|
);
|
|
return;
|
|
};
|
|
if let Some(jsonrpc) = map.get("jsonrpc").and_then(Value::as_str) {
|
|
if jsonrpc != "2.0" {
|
|
self.push_event(
|
|
sid,
|
|
now,
|
|
LspEventKind::ProtocolError {
|
|
message: format!("unexpected jsonrpc version: {jsonrpc:?}"),
|
|
},
|
|
);
|
|
// Continue: a permissive server might still produce
|
|
// a usable message.
|
|
}
|
|
} else {
|
|
self.push_event(
|
|
sid,
|
|
now,
|
|
LspEventKind::ProtocolError {
|
|
message: "frame missing 'jsonrpc' field".into(),
|
|
},
|
|
);
|
|
}
|
|
let id = map.get("id").cloned();
|
|
let method = map.get("method").and_then(Value::as_str).map(str::to_owned);
|
|
let params = map.get("params").cloned().unwrap_or(Value::Null);
|
|
let result = map.get("result").cloned();
|
|
let error = map.get("error").map(LspError::from_value);
|
|
match (id, method, result.is_some() || error.is_some()) {
|
|
// Response: has id and (result or error), no method.
|
|
(Some(idv), None, true) => self.handle_response(sid, &idv, result, error, now),
|
|
// Request: has id and method.
|
|
(Some(idv), Some(m), false) => self.handle_request(sid, idv, m, params, now),
|
|
// Notification: no id, has method.
|
|
(None, Some(m), false) => self.handle_notification(sid, m, params, now),
|
|
// Anything else is a protocol violation we can't repair.
|
|
_ => self.push_event(
|
|
sid,
|
|
now,
|
|
LspEventKind::ProtocolError {
|
|
message: "frame is neither request, response, nor notification".into(),
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
fn handle_response(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
idv: &Value,
|
|
result: Option<Value>,
|
|
error: Option<LspError>,
|
|
now: Instant,
|
|
) {
|
|
let Some(rid) = idv.as_u64() else {
|
|
self.push_event(
|
|
sid,
|
|
now,
|
|
LspEventKind::ProtocolError {
|
|
message: format!("response id is not a u64: {idv:?}"),
|
|
},
|
|
);
|
|
return;
|
|
};
|
|
let method = {
|
|
let Some(client) = self.clients.get_mut(&sid) else {
|
|
return;
|
|
};
|
|
// T M4.5 task #7: the cancel/response race. We sent
|
|
// `$/cancelRequest` for this id after every awaiter
|
|
// abandoned it; a server may answer anyway. By definition
|
|
// no awaiter is listening, so drop it silently rather
|
|
// than emitting a `ProtocolError` for an expected outcome.
|
|
if client.cancelled_rids.remove(&rid) {
|
|
return;
|
|
}
|
|
client.pending.remove(&rid).unwrap_or_default()
|
|
};
|
|
if method.is_empty() {
|
|
self.push_event(
|
|
sid,
|
|
now,
|
|
LspEventKind::ProtocolError {
|
|
message: format!("response for unknown request id {rid}"),
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
// Special-case lifecycle responses: initialize and shutdown.
|
|
if method == "initialize" {
|
|
let result_val = result.clone().unwrap_or(Value::Null);
|
|
let caps = result_val
|
|
.get("capabilities")
|
|
.cloned()
|
|
.unwrap_or(Value::Null);
|
|
let server_info = result_val.get("serverInfo").cloned();
|
|
// Send `initialized` notification per the LSP spec.
|
|
let body = make_notification("initialized", json!({}));
|
|
if let Some(client) = self.clients.get(&sid) {
|
|
let _ = send_frame_to(&self.supervisor, client, &body);
|
|
}
|
|
// T M4.5 Option B: honour the server's negotiated
|
|
// `general.positionEncoding`. Absent ⇒ LSP spec default
|
|
// (UTF-16). We advertised `["utf-8","utf-16"]`, so a
|
|
// 3.17 server echoes its pick here; pre-3.17 servers omit
|
|
// it and are correctly treated as UTF-16.
|
|
let negotiated = PositionEncoding::from_negotiated(
|
|
caps.get("positionEncoding").and_then(Value::as_str),
|
|
);
|
|
if let Some(client) = self.clients.get_mut(&sid) {
|
|
client.position_encoding = negotiated;
|
|
client.state = LspClientState::Initialized {
|
|
capabilities: caps.clone(),
|
|
server_info,
|
|
initialized_at: now,
|
|
};
|
|
}
|
|
// The handshake is complete: replay every notification
|
|
// buffered while the server was still initializing, after
|
|
// the `initialized` notification that went out above.
|
|
self.flush_deferred_notifications(sid);
|
|
self.push_event(sid, now, LspEventKind::Initialized { capabilities: caps });
|
|
return;
|
|
}
|
|
if method == "shutdown" {
|
|
// Send the `exit` notification and wait for the
|
|
// process to actually exit (handled by on_exit).
|
|
let body = make_notification("exit", Value::Null);
|
|
if let Some(client) = self.clients.get(&sid) {
|
|
let _ = send_frame_to(&self.supervisor, client, &body);
|
|
}
|
|
if let Some(client) = self.clients.get_mut(&sid) {
|
|
client.state = LspClientState::ShuttingDown {
|
|
shutdown_request_id: None,
|
|
};
|
|
}
|
|
self.push_event(sid, now, LspEventKind::ShuttingDown);
|
|
return;
|
|
}
|
|
// T M4.7: routed responses (completion / hover / signature
|
|
// help) get absorbed into the matching shared store before
|
|
// surfacing as a generic `Response` event. Consumers
|
|
// observing the event in the same tick see the fresh data.
|
|
if let Some(route) = self.pending_routes.remove(&(sid, rid))
|
|
&& error.is_none()
|
|
&& let Some(value) = result.as_ref()
|
|
{
|
|
self.absorb_routed_response(sid, &route, value);
|
|
}
|
|
// T M4.5 async bridge: settle every awaiter parked on this
|
|
// request. Deliberately independent of the store-absorb guard
|
|
// above — a null result (e.g. "no hover here") is still a
|
|
// successful response and must wake `:await()` with `nil`
|
|
// rather than leave the coroutine parked forever. A server
|
|
// error settles the awaiter as failed so `:await()` raises the
|
|
// structured `{ tag = "failed" }`. The typed store was already
|
|
// populated above when present (hybrid model); popup/gutter
|
|
// consumers are unaffected by this block.
|
|
if let Some(p) = self.pending_external.remove(&(sid, rid)) {
|
|
if let Some(err) = error.as_ref() {
|
|
let msg = format!("LSP {} error {}: {}", p.method, err.code, err.message);
|
|
for a in &p.awaiters {
|
|
self.runtime.complete_external_failed(a.job_id, msg.clone());
|
|
}
|
|
} else {
|
|
let value = result.clone().unwrap_or(Value::Null);
|
|
for a in &p.awaiters {
|
|
self.runtime.complete_external_ok(a.job_id, value.clone());
|
|
}
|
|
}
|
|
}
|
|
// Generic response.
|
|
self.push_event(
|
|
sid,
|
|
now,
|
|
LspEventKind::Response {
|
|
id: rid,
|
|
result: result.unwrap_or(Value::Null),
|
|
error,
|
|
method,
|
|
},
|
|
);
|
|
}
|
|
|
|
// One arm per `ResponseRoute` variant — grows by a few lines with
|
|
// each new typed store; the dispatch stays a flat match.
|
|
#[allow(clippy::too_many_lines)]
|
|
fn absorb_routed_response(&self, sid: LspServerId, route: &ResponseRoute, result: &Value) {
|
|
// T M4.5 Option B: normalise every `Position` in the response
|
|
// to pmacs byte offsets *before* the typed store parses it, so
|
|
// the completion popup, diagnostics gutter, and lsp.lua all
|
|
// stay byte-uniform with zero per-consumer changes.
|
|
let converted = self.inbound_converted(sid, route.uri(), result);
|
|
let result = &converted;
|
|
let server_key = sid.raw().to_string();
|
|
match route {
|
|
ResponseRoute::Completion { uri } => {
|
|
let resp = crate::completion::CompletionResponse::from_lsp_value(result);
|
|
let key = crate::completion::CompletionKey::new(server_key, uri.clone());
|
|
let mut guard = self
|
|
.completion_store
|
|
.lock()
|
|
.expect("completion store mutex poisoned");
|
|
guard.set(key, resp);
|
|
}
|
|
ResponseRoute::Hover { uri } => {
|
|
let key = crate::hover::HoverKey::new(server_key, uri.clone());
|
|
let mut guard = self.hover_store.lock().expect("hover store mutex poisoned");
|
|
if let Some(h) = crate::hover::Hover::from_lsp_value(result) {
|
|
guard.set(key, h);
|
|
} else {
|
|
guard.clear(&key);
|
|
}
|
|
}
|
|
ResponseRoute::Signature { uri } => {
|
|
let help = crate::signature::SignatureHelp::from_lsp_value(result);
|
|
let key = crate::signature::SignatureKey::new(server_key, uri.clone());
|
|
let mut guard = self
|
|
.signature_store
|
|
.lock()
|
|
.expect("signature store mutex poisoned");
|
|
guard.set(key, help);
|
|
}
|
|
ResponseRoute::Definition { uri } => {
|
|
let resp = crate::definition::DefinitionResponse::from_lsp_value(result);
|
|
let key = crate::definition::DefinitionKey::new(server_key, uri.clone());
|
|
let mut guard = self
|
|
.definition_store
|
|
.lock()
|
|
.expect("definition store mutex poisoned");
|
|
guard.set(key, resp);
|
|
}
|
|
ResponseRoute::Locations { uri, kind } => {
|
|
// Same Location parser as definition; only the store
|
|
// key carries the kind discriminator.
|
|
let resp = crate::definition::DefinitionResponse::from_lsp_value(result);
|
|
let key = crate::locations::LocationsKey::new(server_key, uri.clone(), *kind);
|
|
let mut guard = self
|
|
.locations_store
|
|
.lock()
|
|
.expect("locations store mutex poisoned");
|
|
guard.set(key, resp);
|
|
}
|
|
ResponseRoute::DocumentSymbol { uri } => {
|
|
// DocumentSymbol items carry no URI — they belong to
|
|
// the requested document; pass it as the fallback.
|
|
let resp = crate::symbol::SymbolResponse::from_lsp_value(result, uri);
|
|
let key = crate::symbol::SymbolKey::document(server_key, uri.clone());
|
|
let mut guard = self
|
|
.symbol_store
|
|
.lock()
|
|
.expect("symbol store mutex poisoned");
|
|
guard.set(key, resp);
|
|
}
|
|
ResponseRoute::WorkspaceSymbol { query } => {
|
|
let resp = crate::symbol::SymbolResponse::from_lsp_value(result, "");
|
|
let key = crate::symbol::SymbolKey::workspace(server_key, query.clone());
|
|
let mut guard = self
|
|
.symbol_store
|
|
.lock()
|
|
.expect("symbol store mutex poisoned");
|
|
guard.set(key, resp);
|
|
}
|
|
ResponseRoute::DocumentHighlight { uri } => {
|
|
let resp =
|
|
crate::document_highlight::DocumentHighlightResponse::from_lsp_value(result);
|
|
let key =
|
|
crate::document_highlight::DocumentHighlightKey::new(server_key, uri.clone());
|
|
let mut guard = self
|
|
.document_highlight_store
|
|
.lock()
|
|
.expect("document highlight store mutex poisoned");
|
|
guard.set(key, resp);
|
|
}
|
|
ResponseRoute::Formatting { uri } => {
|
|
let resp = crate::formatting::FormattingResponse::from_lsp_value(result);
|
|
let key = crate::formatting::FormattingKey::new(server_key, uri.clone());
|
|
let mut guard = self
|
|
.formatting_store
|
|
.lock()
|
|
.expect("formatting store mutex poisoned");
|
|
guard.set(key, resp);
|
|
}
|
|
ResponseRoute::Rename { uri } => {
|
|
let resp = crate::rename::WorkspaceEditResponse::from_lsp_value(result);
|
|
let key = crate::rename::RenameKey::new(server_key, uri.clone());
|
|
let mut guard = self
|
|
.rename_store
|
|
.lock()
|
|
.expect("rename store mutex poisoned");
|
|
guard.set(key, resp);
|
|
}
|
|
ResponseRoute::PrepareRename { uri } => {
|
|
let resp = crate::prepare_rename::PrepareRenameResponse::from_lsp_value(result);
|
|
let key = crate::prepare_rename::PrepareRenameKey::new(server_key, uri.clone());
|
|
let mut guard = self
|
|
.prepare_rename_store
|
|
.lock()
|
|
.expect("prepare rename store mutex poisoned");
|
|
guard.set(key, resp);
|
|
}
|
|
ResponseRoute::CodeAction { uri } => {
|
|
let resp = crate::code_action::CodeActionResponse::from_lsp_value(result);
|
|
let key = crate::code_action::CodeActionKey::new(server_key, uri.clone());
|
|
let mut guard = self
|
|
.code_action_store
|
|
.lock()
|
|
.expect("code action store mutex poisoned");
|
|
guard.set(key, resp);
|
|
}
|
|
ResponseRoute::InlayHint { uri } => {
|
|
let resp = crate::inlay_hint::InlayHintResponse::from_lsp_value(result);
|
|
let key = crate::inlay_hint::InlayHintKey::new(server_key, uri.clone());
|
|
let mut guard = self
|
|
.inlay_hint_store
|
|
.lock()
|
|
.expect("inlay hint store mutex poisoned");
|
|
guard.set(key, resp);
|
|
}
|
|
ResponseRoute::SemanticTokens { uri } => {
|
|
let resp = crate::semantic_tokens::SemanticTokensResponse::from_lsp_value(result);
|
|
let key = crate::semantic_tokens::SemanticTokenKey::new(server_key, uri.clone());
|
|
let mut guard = self
|
|
.semantic_token_store
|
|
.lock()
|
|
.expect("semantic token store mutex poisoned");
|
|
guard.set(key, resp);
|
|
}
|
|
ResponseRoute::SemanticTokensDelta { uri } => {
|
|
let key = crate::semantic_tokens::SemanticTokenKey::new(server_key, uri.clone());
|
|
let mut guard = self
|
|
.semantic_token_store
|
|
.lock()
|
|
.expect("semantic token store mutex poisoned");
|
|
// Splice against whatever raw stream the previous
|
|
// full/delta left here; empty if none (the server
|
|
// should then have answered full, which apply_delta
|
|
// detects and parses).
|
|
let prev_raw = guard.get(&key).map(|r| r.raw.clone()).unwrap_or_default();
|
|
let resp =
|
|
crate::semantic_tokens::SemanticTokensResponse::apply_delta(&prev_raw, result);
|
|
guard.set(key, resp);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_request(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
idv: Value,
|
|
method: String,
|
|
params: Value,
|
|
now: Instant,
|
|
) {
|
|
// T M4.5: answer `workspace/configuration` ourselves — it's a
|
|
// protocol-level pull (gopls/pyright/clangd issue it during
|
|
// startup and degrade without a reply), not something to defer
|
|
// to a Lua consumer. One array element per requested item;
|
|
// each `section` resolves against the server's `settings`,
|
|
// unknown sections answer `null` per spec.
|
|
if method == "workspace/configuration" {
|
|
let settings = self
|
|
.clients
|
|
.get(&sid)
|
|
.and_then(|c| c.spec.settings.clone())
|
|
.unwrap_or(Value::Null);
|
|
let answers: Vec<Value> = params
|
|
.get("items")
|
|
.and_then(Value::as_array)
|
|
.map(|items| {
|
|
items
|
|
.iter()
|
|
.map(|item| {
|
|
resolve_config_section(
|
|
&settings,
|
|
item.get("section").and_then(Value::as_str),
|
|
)
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
let _ = self.send_response(sid, idv, Ok(Value::Array(answers)));
|
|
return;
|
|
}
|
|
// Everything else: expose the request to the consumer and let
|
|
// it reply via `send_response`. The LSP spec tolerates
|
|
// delayed responses; unrecognised requests simply linger.
|
|
self.push_event(
|
|
sid,
|
|
now,
|
|
LspEventKind::Request {
|
|
id: idv,
|
|
method,
|
|
params,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn handle_notification(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
method: String,
|
|
params: Value,
|
|
now: Instant,
|
|
) {
|
|
// T M4.6: intercept `textDocument/publishDiagnostics`
|
|
// before forwarding the event so the diagnostic store is
|
|
// updated synchronously --- a consumer that re-queries the
|
|
// store from inside the same tick (e.g. a render observing
|
|
// a fresh notification) sees the new diagnostics.
|
|
if method == "textDocument/publishDiagnostics" {
|
|
self.absorb_publish_diagnostics(sid, ¶ms);
|
|
}
|
|
self.push_event(sid, now, LspEventKind::Notification { method, params });
|
|
}
|
|
|
|
/// Parse `params` as a `PublishDiagnosticsParams` payload and
|
|
/// update [`Self::diag_store`]. Malformed payloads (missing uri
|
|
/// or non-array diagnostics) are ignored --- they're a
|
|
/// server-side bug, not a fatal protocol error.
|
|
fn absorb_publish_diagnostics(&self, sid: LspServerId, params: &Value) {
|
|
let Some(uri) = params.get("uri").and_then(Value::as_str).map(str::to_owned) else {
|
|
return;
|
|
};
|
|
// T M4.5 Option B: byte-normalise diagnostic ranges before the
|
|
// store parses them, so the gutter renders correct spans on
|
|
// non-ASCII lines.
|
|
let converted = self.inbound_converted(sid, &uri, params);
|
|
let Some(arr) = converted.get("diagnostics").and_then(Value::as_array) else {
|
|
return;
|
|
};
|
|
let parsed: Vec<crate::diag::Diagnostic> = arr
|
|
.iter()
|
|
.filter_map(crate::diag::Diagnostic::from_lsp_value)
|
|
.collect();
|
|
let mut guard = self.diag_store.lock().expect("diag store mutex poisoned");
|
|
guard.set(uri, parsed);
|
|
}
|
|
|
|
fn maybe_restart(&mut self, sid: LspServerId) {
|
|
let now = Instant::now();
|
|
let restart_now = {
|
|
let Some(client) = self.clients.get(&sid) else {
|
|
return;
|
|
};
|
|
let LspClientState::Crashed { .. } = &client.state else {
|
|
return;
|
|
};
|
|
match client.next_restart_at {
|
|
Some(at) => at <= now,
|
|
None => false,
|
|
}
|
|
};
|
|
if !restart_now {
|
|
return;
|
|
}
|
|
let attempt = {
|
|
let Some(client) = self.clients.get_mut(&sid) else {
|
|
return;
|
|
};
|
|
client.attempt + 1
|
|
};
|
|
self.push_event(sid, now, LspEventKind::Restarting { attempt });
|
|
// Detach the entry temporarily so `start_generation` (which
|
|
// also borrows `self.process_to_server`) doesn't conflict
|
|
// with the `clients.get_mut` borrow.
|
|
let mut client = self.clients.remove(&sid).expect("checked existence above");
|
|
let _ = self.start_generation(sid, &mut client);
|
|
self.clients.insert(sid, client);
|
|
}
|
|
|
|
fn push_event(&mut self, sid: LspServerId, at: Instant, kind: LspEventKind) {
|
|
let event = LspEvent {
|
|
server: sid,
|
|
kind,
|
|
at,
|
|
};
|
|
// Feed every event through the status tracker before
|
|
// queueing it so a consumer that calls `status_for` after
|
|
// `take_events` sees the post-event state.
|
|
let client_state = self.clients.get(&sid).map(|c| c.state.clone());
|
|
self.status_tracker.observe(&event, client_state.as_ref());
|
|
self.pending.entry(sid).or_default().push(event);
|
|
}
|
|
|
|
/// Drain the pending event queue for `sid`. Returns an empty
|
|
/// vec for unknown ids.
|
|
pub fn take_events(&mut self, sid: LspServerId) -> Vec<LspEvent> {
|
|
self.pending.remove(&sid).unwrap_or_default()
|
|
}
|
|
|
|
/// Drain every pending event across every server. Returns
|
|
/// events sorted by enqueue time.
|
|
pub fn take_all_events(&mut self) -> Vec<LspEvent> {
|
|
let mut all = Vec::new();
|
|
for (_id, mut evs) in std::mem::take(&mut self.pending) {
|
|
all.append(&mut evs);
|
|
}
|
|
all.sort_by_key(|e| e.at);
|
|
all
|
|
}
|
|
|
|
/// Iterator over every server id, in arbitrary order.
|
|
pub fn ids(&self) -> impl Iterator<Item = LspServerId> + '_ {
|
|
self.clients.keys().copied()
|
|
}
|
|
|
|
/// Server state for `sid`, or `None` if the id is unknown.
|
|
#[must_use]
|
|
pub fn state(&self, sid: LspServerId) -> Option<&LspClientState> {
|
|
self.clients.get(&sid).map(|c| &c.state)
|
|
}
|
|
|
|
/// Server spec for `sid`, or `None` if the id is unknown.
|
|
#[must_use]
|
|
pub fn spec(&self, sid: LspServerId) -> Option<&LspServerSpec> {
|
|
self.clients.get(&sid).map(|c| &c.spec)
|
|
}
|
|
|
|
/// Server capabilities for `sid`, or `None` if the id is unknown
|
|
/// or the server isn't initialized.
|
|
#[must_use]
|
|
pub fn capabilities(&self, sid: LspServerId) -> Option<&Value> {
|
|
self.clients.get(&sid).and_then(LspClient::capabilities)
|
|
}
|
|
|
|
/// Cumulative spawn-attempt count for `sid`. 1 = first spawn,
|
|
/// 2 = first restart, ...
|
|
#[must_use]
|
|
pub fn attempt(&self, sid: LspServerId) -> Option<u32> {
|
|
self.clients.get(&sid).map(|c| c.attempt)
|
|
}
|
|
|
|
/// Forget about `sid`. Server must already be in a terminal
|
|
/// state.
|
|
pub fn forget(&mut self, sid: LspServerId) -> Result<(), String> {
|
|
let client = self
|
|
.clients
|
|
.get(&sid)
|
|
.ok_or_else(|| format!("unknown server: {sid}"))?;
|
|
if !matches!(
|
|
client.state,
|
|
LspClientState::Stopped { .. } | LspClientState::Crashed { .. }
|
|
) {
|
|
return Err(format!("server {sid} is not in a terminal state"));
|
|
}
|
|
self.clients.remove(&sid);
|
|
self.pending.remove(&sid);
|
|
self.pending_routes.retain(|(s, _), _| *s != sid);
|
|
// T M4.5: belt-and-braces — on_exit already drained on the
|
|
// terminal transition; this catches any awaiter registered
|
|
// between exit and forget. Idempotent.
|
|
self.drain_external_cancelled(sid);
|
|
self.documents.retain(|(s, _), _| *s != sid);
|
|
self.status_tracker.forget(sid);
|
|
// T M4.9: drop the project scoping so the next
|
|
// ensure_server_for_project call spawns a fresh server.
|
|
self.project_servers.retain(|_, v| *v != sid);
|
|
Ok(())
|
|
}
|
|
|
|
/// Convenience: send `textDocument/didOpen` to `sid`.
|
|
pub fn did_open(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
version: i64,
|
|
text: impl Into<String>,
|
|
) -> Result<(), String> {
|
|
let language_id = self
|
|
.clients
|
|
.get(&sid)
|
|
.map(|c| c.spec.language_id.clone())
|
|
.ok_or_else(|| format!("unknown server: {sid}"))?;
|
|
let uri = uri.into();
|
|
let text = text.into();
|
|
// T M4.5 Option B: mirror the document so the position codec
|
|
// can convert per-line between the server's `character` units
|
|
// and pmacs byte offsets.
|
|
self.documents.insert((sid, uri.clone()), text.clone());
|
|
let params = json!({
|
|
"textDocument": {
|
|
"uri": uri,
|
|
"languageId": language_id,
|
|
"version": version,
|
|
"text": text,
|
|
}
|
|
});
|
|
self.send_notification(sid, "textDocument/didOpen", params)
|
|
}
|
|
|
|
/// Convenience: send `textDocument/didChange` to `sid` with full
|
|
/// text replacement (the simplest sync mode; M5+ may add
|
|
/// incremental sync).
|
|
pub fn did_change_full(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
uri: impl Into<String>,
|
|
version: i64,
|
|
text: impl Into<String>,
|
|
) -> Result<(), String> {
|
|
let uri = uri.into();
|
|
let text = text.into();
|
|
self.documents.insert((sid, uri.clone()), text.clone());
|
|
self.mark_document_stale(&uri);
|
|
let params = json!({
|
|
"textDocument": {
|
|
"uri": uri,
|
|
"version": version,
|
|
},
|
|
"contentChanges": [{
|
|
"text": text,
|
|
}],
|
|
});
|
|
self.send_notification(sid, "textDocument/didChange", params)
|
|
}
|
|
|
|
/// T M11.8 / Session 8 — mark cached LSP-derived render families
|
|
/// for `uri` stale so frontends suppress byte ranges anchored to
|
|
/// pre-edit text until the server refreshes them. Diagnostics
|
|
/// clear on `publishDiagnostics`, semantic tokens on
|
|
/// `textDocument/semanticTokens`, and inlay hints on
|
|
/// `textDocument/inlayHint`.
|
|
///
|
|
/// Factored out of [`Self::did_change_full`] so the Lua glue can
|
|
/// mark staleness at *edit* time even while the (full-document,
|
|
/// O(file)) didChange notification itself is debounced — per-edit
|
|
/// staleness is what keeps stale-position artifacts off screen.
|
|
pub fn mark_document_stale(&self, uri: &str) {
|
|
self.diag_store
|
|
.lock()
|
|
.expect("diag store mutex poisoned")
|
|
.mark_stale(uri.to_owned());
|
|
self.semantic_token_store
|
|
.lock()
|
|
.expect("semantic token store mutex poisoned")
|
|
.mark_stale(uri.to_owned());
|
|
self.inlay_hint_store
|
|
.lock()
|
|
.expect("inlay hint store mutex poisoned")
|
|
.mark_stale(uri.to_owned());
|
|
}
|
|
|
|
/// Send `workspace/didChangeWatchedFiles` to `sid`. `changes` is
|
|
/// the already-shaped `FileEvent[]` array (`[{ uri, type }]`,
|
|
/// type 1=created / 2=changed / 3=deleted) the Lua file-watch
|
|
/// module builds. T M4.5.
|
|
pub fn did_change_watched_files(
|
|
&mut self,
|
|
sid: LspServerId,
|
|
changes: &Value,
|
|
) -> Result<(), String> {
|
|
self.send_notification(
|
|
sid,
|
|
"workspace/didChangeWatchedFiles",
|
|
json!({ "changes": changes }),
|
|
)
|
|
}
|
|
|
|
/// Convenience: send `textDocument/didClose` to `sid`.
|
|
pub fn did_close(&mut self, sid: LspServerId, uri: impl Into<String>) -> Result<(), String> {
|
|
let uri = uri.into();
|
|
self.documents.remove(&(sid, uri.clone()));
|
|
let params = json!({
|
|
"textDocument": { "uri": uri },
|
|
});
|
|
self.send_notification(sid, "textDocument/didClose", params)
|
|
}
|
|
|
|
/// Initiate shutdown for every server. The supervisor's own
|
|
/// shutdown will SIGTERM/SIGKILL anything that hasn't actually
|
|
/// exited by the time the editor drops; this is the LSP-layer
|
|
/// polite path.
|
|
pub fn shutdown_all(&mut self) {
|
|
let ids: Vec<LspServerId> = self.clients.keys().copied().collect();
|
|
for id in ids {
|
|
let _ = self.stop(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn next_request_id(client: &mut LspClient) -> u64 {
|
|
let id = client.next_request_id;
|
|
client.next_request_id += 1;
|
|
id
|
|
}
|
|
|
|
fn is_ready_for_request(state: &LspClientState) -> bool {
|
|
matches!(state, LspClientState::Initialized { .. })
|
|
}
|
|
|
|
fn state_label(state: &LspClientState) -> &'static str {
|
|
match state {
|
|
LspClientState::Starting => "starting",
|
|
LspClientState::Initializing { .. } => "initializing",
|
|
LspClientState::Initialized { .. } => "initialized",
|
|
LspClientState::ShuttingDown { .. } => "shutting-down",
|
|
LspClientState::Stopped { .. } => "stopped",
|
|
LspClientState::Crashed { .. } => "crashed",
|
|
}
|
|
}
|
|
|
|
fn should_restart(policy: LspRestartPolicy) -> bool {
|
|
matches!(policy, LspRestartPolicy::OnCrash | LspRestartPolicy::Always)
|
|
}
|
|
|
|
fn make_request(id: u64, method: &str, params: Value) -> Vec<u8> {
|
|
let mut body = Map::new();
|
|
body.insert("jsonrpc".into(), Value::String("2.0".into()));
|
|
body.insert("id".into(), id.into());
|
|
body.insert("method".into(), Value::String(method.to_owned()));
|
|
body.insert("params".into(), params);
|
|
let bytes = serde_json::to_vec(&Value::Object(body)).expect("json serialize");
|
|
encode_frame(&bytes)
|
|
}
|
|
|
|
fn make_notification(method: &str, params: Value) -> Vec<u8> {
|
|
let mut body = Map::new();
|
|
body.insert("jsonrpc".into(), Value::String("2.0".into()));
|
|
body.insert("method".into(), Value::String(method.to_owned()));
|
|
body.insert("params".into(), params);
|
|
let bytes = serde_json::to_vec(&Value::Object(body)).expect("json serialize");
|
|
encode_frame(&bytes)
|
|
}
|
|
|
|
fn make_response(id: Value, result: Result<Value, LspError>) -> Vec<u8> {
|
|
let mut body = Map::new();
|
|
body.insert("jsonrpc".into(), Value::String("2.0".into()));
|
|
body.insert("id".into(), id);
|
|
match result {
|
|
Ok(v) => {
|
|
body.insert("result".into(), v);
|
|
}
|
|
Err(e) => {
|
|
let mut err_obj = Map::new();
|
|
err_obj.insert("code".into(), e.code.into());
|
|
err_obj.insert("message".into(), Value::String(e.message));
|
|
if let Some(d) = e.data {
|
|
err_obj.insert("data".into(), d);
|
|
}
|
|
body.insert("error".into(), Value::Object(err_obj));
|
|
}
|
|
}
|
|
let bytes = serde_json::to_vec(&Value::Object(body)).expect("json serialize");
|
|
encode_frame(&bytes)
|
|
}
|
|
|
|
fn send_frame_to(
|
|
supervisor: &crate::lua_bindings::SharedProcessSupervisor,
|
|
client: &LspClient,
|
|
framed: &[u8],
|
|
) -> Result<(), String> {
|
|
let pid = client
|
|
.process
|
|
.ok_or_else(|| "server has no live process".to_owned())?;
|
|
supervisor.borrow_mut().write_stdin(pid, framed)
|
|
}
|
|
|
|
/// Conservative default LSP client capabilities. Covers the
|
|
/// transports M4.6/M4.7 will need (diagnostics push, completion,
|
|
/// hover, signature help, definition).
|
|
fn default_capabilities() -> Value {
|
|
json!({
|
|
// T M4.5 Option B: LSP 3.17 position-encoding negotiation.
|
|
// UTF-8 first (identity for pmacs's byte offsets); UTF-16 is
|
|
// the mandatory fallback every server understands.
|
|
"general": {
|
|
"positionEncodings": ["utf-8", "utf-16"],
|
|
},
|
|
"workspace": {
|
|
// T M4.5 L3: pmacs applies server→client `workspace/
|
|
// applyEdit` requests via the Lua WorkspaceEdit applier
|
|
// (surfaced as a request event, answered with
|
|
// `{ applied }`). executeCommand-driven code actions
|
|
// depend on this.
|
|
"applyEdit": true,
|
|
"executeCommand": { "dynamicRegistration": false },
|
|
// T M4.5: pmacs answers server→client `workspace/configuration`
|
|
// pull requests from the per-server `settings` (see
|
|
// `handle_request`). gopls / pyright / clangd all pull
|
|
// config this way and degrade without it.
|
|
"configuration": true,
|
|
"workspaceFolders": true,
|
|
"didChangeConfiguration": { "dynamicRegistration": false },
|
|
// T M4.5 — file watching. `dynamicRegistration: true` is
|
|
// mandatory: clangd / rust-analyzer / gopls only ever
|
|
// register `workspace/didChangeWatchedFiles` dynamically
|
|
// (via `client/registerCapability`); without it the
|
|
// server never asks us to watch and the feature is dead.
|
|
// The Lua server-request pump handles the registration
|
|
// and runs the snapshot-diff watcher.
|
|
"didChangeWatchedFiles": { "dynamicRegistration": true },
|
|
// T M4.5 — let servers tell us cached inlay hints /
|
|
// semantic tokens are stale via a server→client
|
|
// `workspace/inlayHint/refresh` /
|
|
// `workspace/semanticTokens/refresh` request. The Lua
|
|
// server-request pump answers each and re-pulls the
|
|
// affected documents into the matching store.
|
|
"inlayHint": { "refreshSupport": true },
|
|
"semanticTokens": { "refreshSupport": true },
|
|
},
|
|
"textDocument": {
|
|
"synchronization": {
|
|
"dynamicRegistration": false,
|
|
"willSave": false,
|
|
"willSaveWaitUntil": false,
|
|
"didSave": true,
|
|
},
|
|
"completion": {
|
|
"dynamicRegistration": false,
|
|
"completionItem": {
|
|
"snippetSupport": false,
|
|
"documentationFormat": ["plaintext", "markdown"],
|
|
},
|
|
},
|
|
"hover": {
|
|
"dynamicRegistration": false,
|
|
"contentFormat": ["plaintext", "markdown"],
|
|
},
|
|
"signatureHelp": { "dynamicRegistration": false },
|
|
"definition": { "dynamicRegistration": false, "linkSupport": true },
|
|
"formatting": { "dynamicRegistration": false },
|
|
// T M4.5: client-side rename. `prepareSupport: true` —
|
|
// the rename flow does a `textDocument/prepareRename`
|
|
// round-trip first *when the server advertises
|
|
// `renameProvider.prepareProvider`* (it gates the prompt
|
|
// and pre-fills the placeholder); otherwise it sends
|
|
// `textDocument/rename` straight from the cursor.
|
|
// `prepareSupportDefaultBehavior: 1` (Identifier) tells
|
|
// the server we can handle the `{ defaultBehavior }`
|
|
// shape (compute the word range ourselves).
|
|
"rename": {
|
|
"dynamicRegistration": false,
|
|
"prepareSupport": true,
|
|
"prepareSupportDefaultBehavior": 1,
|
|
},
|
|
// T M4.5 L3: code actions. `codeActionLiteralSupport`
|
|
// tells servers we accept the richer `CodeAction` shape
|
|
// (title/kind/edit/command), not just bare `Command`s.
|
|
"codeAction": {
|
|
"dynamicRegistration": false,
|
|
"codeActionLiteralSupport": {
|
|
"codeActionKind": {
|
|
"valueSet": [
|
|
"quickfix", "refactor", "refactor.extract",
|
|
"refactor.inline", "refactor.rewrite",
|
|
"source", "source.organizeImports",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
// T M4.5 inlay hints. No `resolveSupport` — pmacs
|
|
// requests full hints (label/tooltip already populated),
|
|
// not lazily-resolved stubs. `refreshSupport` is left
|
|
// false (default) so servers don't send
|
|
// `workspace/inlayHint/refresh`; on-demand re-query is
|
|
// the v1 model.
|
|
"inlayHint": { "dynamicRegistration": false },
|
|
// T M4.5 semantic tokens. We support `/full`, the
|
|
// `/full/delta` follow-up, and the `/range` viewport
|
|
// request. `formats: ["relative"]` is the only encoding
|
|
// LSP defines; the tokenTypes/tokenModifiers lists are
|
|
// the LSP-standard legend the client understands — the
|
|
// server intersects its legend with these and reports the
|
|
// agreed legend back via `semanticTokensProvider.legend`.
|
|
"semanticTokens": {
|
|
"dynamicRegistration": false,
|
|
"requests": { "full": { "delta": true }, "range": true },
|
|
"formats": ["relative"],
|
|
"tokenTypes": [
|
|
"namespace", "type", "class", "enum", "interface",
|
|
"struct", "typeParameter", "parameter", "variable",
|
|
"property", "enumMember", "event", "function",
|
|
"method", "macro", "keyword", "modifier", "comment",
|
|
"string", "number", "regexp", "operator", "decorator"
|
|
],
|
|
"tokenModifiers": [
|
|
"declaration", "definition", "readonly", "static",
|
|
"deprecated", "abstract", "async", "modification",
|
|
"documentation", "defaultLibrary"
|
|
],
|
|
},
|
|
"publishDiagnostics": { "relatedInformation": true },
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Canonicalise `root` for use as a key in the project-server map.
|
|
/// Falls back to the path as-given if canonicalisation fails (e.g.
|
|
/// the path doesn't exist on disk, common in unit tests).
|
|
fn canonicalize_root_for_scope(root: &Path) -> PathBuf {
|
|
root.canonicalize().unwrap_or_else(|_| root.to_path_buf())
|
|
}
|
|
|
|
/// `file://` URI encoder. Byte-identical to
|
|
/// `builtin/runtime/lsp.lua`'s `file_uri_for` (same passthrough set),
|
|
/// so a URI built here keys into the same `DiagnosticStore` entry the
|
|
/// Lua LSP glue opened the document under. T M11.3 reuses this from
|
|
/// `crate::semantic_render` for the diagnostics projection — hence
|
|
/// `pub(crate)`.
|
|
pub(crate) fn path_to_file_uri(path: &std::path::Path) -> String {
|
|
// Minimal file:// URI encoder: percent-encode anything outside
|
|
// the LSP-friendly set. Adequate for v0.1 (paths in a typical
|
|
// project root); a fuller URL crate would be overkill here.
|
|
let mut out = String::from("file://");
|
|
for ch in path.display().to_string().chars() {
|
|
match ch {
|
|
'a'..='z' | 'A'..='Z' | '0'..='9' | '/' | '-' | '_' | '.' | '~' | ':' => out.push(ch),
|
|
_ => {
|
|
use std::fmt::Write as _;
|
|
let mut buf = [0u8; 4];
|
|
for byte in ch.encode_utf8(&mut buf).as_bytes() {
|
|
let _ = write!(out, "%{byte:02X}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SharedLspManager
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Main-thread shared handle, mirroring [`crate::lua_bindings::SharedProcessSupervisor`].
|
|
pub type SharedLspManager = Rc<RefCell<LspManager>>;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Termination helpers (used by Lua boundary)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Translate an [`LspClientState`] to a stable `&'static str` label.
|
|
/// Used by the Lua surface and tests so the spelling of state names
|
|
/// is locked down in one place.
|
|
#[must_use]
|
|
pub fn state_label_for(state: &LspClientState) -> &'static str {
|
|
state_label(state)
|
|
}
|
|
|
|
/// True iff `state` is a terminal (won't transition without a
|
|
/// restart). Used by the Lua surface.
|
|
#[must_use]
|
|
pub fn is_terminal_state(state: &LspClientState) -> bool {
|
|
matches!(
|
|
state,
|
|
LspClientState::Stopped { .. } | LspClientState::Crashed { .. }
|
|
)
|
|
}
|
|
|
|
/// Match a [`Termination`] against an [`LspRestartPolicy`]. Exposed
|
|
/// for tests and consumers that inspect the supervisor directly.
|
|
#[must_use]
|
|
pub fn termination_warrants_restart(policy: LspRestartPolicy, termination: &Termination) -> bool {
|
|
match (policy, termination) {
|
|
(LspRestartPolicy::Never, _) => false,
|
|
(LspRestartPolicy::Always, _)
|
|
| (LspRestartPolicy::OnCrash, Termination::Signaled { .. } | Termination::Crashed { .. }) => {
|
|
true
|
|
}
|
|
(LspRestartPolicy::OnCrash, Termination::Exited { code, .. }) => *code != 0,
|
|
}
|
|
}
|
|
|
|
/// Helper for [`crate::editor::EditorState::tick_lsp`]. Returns
|
|
/// `true` iff `state` is `Initialized`.
|
|
#[must_use]
|
|
pub fn is_initialized(state: &LspClientState) -> bool {
|
|
matches!(state, LspClientState::Initialized { .. })
|
|
}
|
|
|
|
/// Map a [`ProcessState`] back to an LSP-friendly string. Used by
|
|
/// the modeline status surface (M4.8) when no LSP-layer state is
|
|
/// available.
|
|
#[must_use]
|
|
pub fn process_state_label(state: &ProcessState) -> &'static str {
|
|
match state {
|
|
ProcessState::Starting => "process-starting",
|
|
ProcessState::Running { .. } => "process-running",
|
|
ProcessState::Exiting { .. } => "process-exiting",
|
|
ProcessState::Terminated(_) => "process-terminated",
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn frame_parser_extracts_a_complete_frame() {
|
|
let mut p = FrameParser::new();
|
|
let body = br#"{"jsonrpc":"2.0","method":"x"}"#;
|
|
let mut bytes = Vec::new();
|
|
bytes.extend_from_slice(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes());
|
|
bytes.extend_from_slice(body);
|
|
p.extend(&bytes);
|
|
let frame = p.next_frame().expect("parse").expect("some frame");
|
|
assert_eq!(frame, body);
|
|
assert!(p.next_frame().expect("parse").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn frame_parser_handles_split_chunks() {
|
|
let mut p = FrameParser::new();
|
|
let body = br#"{"a":1}"#;
|
|
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
|
// Feed byte-by-byte; the parser must reassemble.
|
|
for byte in header.as_bytes() {
|
|
p.extend(std::slice::from_ref(byte));
|
|
assert!(p.next_frame().expect("ok").is_none());
|
|
}
|
|
for (i, byte) in body.iter().enumerate() {
|
|
p.extend(std::slice::from_ref(byte));
|
|
if i + 1 < body.len() {
|
|
assert!(p.next_frame().expect("ok").is_none());
|
|
}
|
|
}
|
|
let frame = p.next_frame().expect("ok").expect("complete");
|
|
assert_eq!(frame, body);
|
|
}
|
|
|
|
#[test]
|
|
fn frame_parser_extracts_two_back_to_back_frames() {
|
|
let mut p = FrameParser::new();
|
|
let mut bytes = Vec::new();
|
|
for body in [&b"{\"a\":1}"[..], &b"{\"b\":2}"[..]] {
|
|
bytes.extend_from_slice(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes());
|
|
bytes.extend_from_slice(body);
|
|
}
|
|
p.extend(&bytes);
|
|
let f1 = p.next_frame().unwrap().unwrap();
|
|
let f2 = p.next_frame().unwrap().unwrap();
|
|
assert_eq!(f1, b"{\"a\":1}");
|
|
assert_eq!(f2, b"{\"b\":2}");
|
|
assert!(p.next_frame().unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn frame_parser_rejects_malformed_header() {
|
|
let mut p = FrameParser::new();
|
|
p.extend(b"NotAHeader\r\n\r\n");
|
|
let err = p.next_frame().unwrap_err();
|
|
assert!(matches!(err, FrameError::MalformedHeader(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn frame_parser_rejects_missing_content_length() {
|
|
let mut p = FrameParser::new();
|
|
p.extend(b"Content-Type: utf-8\r\n\r\n{}");
|
|
let err = p.next_frame().unwrap_err();
|
|
assert!(matches!(err, FrameError::MissingContentLength));
|
|
}
|
|
|
|
#[test]
|
|
fn encode_frame_round_trips() {
|
|
let body = br#"{"jsonrpc":"2.0","id":1,"method":"x"}"#;
|
|
let framed = encode_frame(body);
|
|
let mut p = FrameParser::new();
|
|
p.extend(&framed);
|
|
let parsed = p.next_frame().unwrap().unwrap();
|
|
assert_eq!(parsed, body);
|
|
}
|
|
|
|
#[test]
|
|
fn make_request_includes_jsonrpc_id_and_method() {
|
|
let bytes = make_request(7, "echo", json!({"x": 1}));
|
|
let mut p = FrameParser::new();
|
|
p.extend(&bytes);
|
|
let body = p.next_frame().unwrap().unwrap();
|
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(v["jsonrpc"], "2.0");
|
|
assert_eq!(v["id"], 7);
|
|
assert_eq!(v["method"], "echo");
|
|
assert_eq!(v["params"]["x"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn make_notification_omits_id() {
|
|
let bytes = make_notification("ping", Value::Null);
|
|
let mut p = FrameParser::new();
|
|
p.extend(&bytes);
|
|
let body = p.next_frame().unwrap().unwrap();
|
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
|
assert!(v.get("id").is_none());
|
|
assert_eq!(v["method"], "ping");
|
|
}
|
|
|
|
#[test]
|
|
fn path_to_file_uri_encodes_spaces() {
|
|
let p = std::path::Path::new("/tmp/a b/file.rs");
|
|
let uri = path_to_file_uri(p);
|
|
assert!(uri.starts_with("file:///tmp/a"));
|
|
assert!(uri.contains("%20"));
|
|
assert!(uri.ends_with("/file.rs"));
|
|
}
|
|
|
|
#[test]
|
|
fn lsp_error_from_value_handles_optional_data() {
|
|
let v = json!({"code": -32603, "message": "Internal", "data": {"trace": "stack"}});
|
|
let e = LspError::from_value(&v);
|
|
assert_eq!(e.code, -32603);
|
|
assert_eq!(e.message, "Internal");
|
|
assert!(e.data.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn termination_policy_matrix() {
|
|
let exit_clean = Termination::Exited {
|
|
code: 0,
|
|
started: Instant::now(),
|
|
ended: Instant::now(),
|
|
};
|
|
let exit_nonzero = Termination::Exited {
|
|
code: 7,
|
|
started: Instant::now(),
|
|
ended: Instant::now(),
|
|
};
|
|
let signaled = Termination::Signaled {
|
|
signal: "SIGTERM".into(),
|
|
started: Instant::now(),
|
|
ended: Instant::now(),
|
|
};
|
|
assert!(!termination_warrants_restart(
|
|
LspRestartPolicy::Never,
|
|
&exit_clean
|
|
));
|
|
assert!(!termination_warrants_restart(
|
|
LspRestartPolicy::Never,
|
|
&signaled
|
|
));
|
|
assert!(termination_warrants_restart(
|
|
LspRestartPolicy::Always,
|
|
&exit_clean
|
|
));
|
|
assert!(!termination_warrants_restart(
|
|
LspRestartPolicy::OnCrash,
|
|
&exit_clean
|
|
));
|
|
assert!(termination_warrants_restart(
|
|
LspRestartPolicy::OnCrash,
|
|
&exit_nonzero
|
|
));
|
|
assert!(termination_warrants_restart(
|
|
LspRestartPolicy::OnCrash,
|
|
&signaled
|
|
));
|
|
}
|
|
|
|
// ---- T M4.5 Option B: position codec -------------------------------
|
|
|
|
#[test]
|
|
fn position_encoding_negotiation_defaults_to_utf16() {
|
|
assert_eq!(
|
|
PositionEncoding::from_negotiated(Some("utf-8")),
|
|
PositionEncoding::Utf8
|
|
);
|
|
assert_eq!(
|
|
PositionEncoding::from_negotiated(Some("utf-16")),
|
|
PositionEncoding::Utf16
|
|
);
|
|
// Absent or unrecognised ⇒ LSP spec default (UTF-16).
|
|
assert_eq!(
|
|
PositionEncoding::from_negotiated(None),
|
|
PositionEncoding::Utf16
|
|
);
|
|
assert_eq!(
|
|
PositionEncoding::from_negotiated(Some("utf-32")),
|
|
PositionEncoding::Utf16
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codec_utf8_is_identity_on_char_boundaries() {
|
|
// "é=x": é = 2 bytes (U+00E9), '=' , 'x' → len 4.
|
|
let line = "é=x";
|
|
for &b in &[0usize, 2, 3, 4] {
|
|
assert_eq!(byte_to_char(line, b, PositionEncoding::Utf8), b as u32);
|
|
assert_eq!(char_to_byte(line, b as u32, PositionEncoding::Utf8), b);
|
|
}
|
|
// A mid-codepoint byte snaps down to the char boundary.
|
|
assert_eq!(byte_to_char(line, 1, PositionEncoding::Utf8), 0);
|
|
assert_eq!(char_to_byte(line, 1, PositionEncoding::Utf8), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn codec_utf16_round_trips_through_non_ascii() {
|
|
// "é=x": UTF-16 units é=0 '='=1 'x'=2 ; bytes é=0..2 '='=2 'x'=3.
|
|
let line = "é=x";
|
|
// byte → utf16
|
|
assert_eq!(byte_to_char(line, 0, PositionEncoding::Utf16), 0);
|
|
assert_eq!(byte_to_char(line, 2, PositionEncoding::Utf16), 1); // '='
|
|
assert_eq!(byte_to_char(line, 3, PositionEncoding::Utf16), 2); // 'x'
|
|
// utf16 → byte
|
|
assert_eq!(char_to_byte(line, 0, PositionEncoding::Utf16), 0);
|
|
assert_eq!(char_to_byte(line, 1, PositionEncoding::Utf16), 2);
|
|
assert_eq!(char_to_byte(line, 2, PositionEncoding::Utf16), 3);
|
|
// Round-trip both directions over every boundary.
|
|
for &byte in &[0usize, 2, 3] {
|
|
let c = byte_to_char(line, byte, PositionEncoding::Utf16);
|
|
assert_eq!(char_to_byte(line, c, PositionEncoding::Utf16), byte);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn codec_utf16_handles_astral_surrogate_pair() {
|
|
// "🦀x": U+1F980 is 4 UTF-8 bytes and a UTF-16 surrogate pair
|
|
// (2 code units). 'x' is byte 4, UTF-16 unit 2.
|
|
let line = "🦀x";
|
|
assert_eq!(byte_to_char(line, 4, PositionEncoding::Utf16), 2);
|
|
assert_eq!(char_to_byte(line, 2, PositionEncoding::Utf16), 4);
|
|
// A server pointing inside the surrogate pair (unit 1) clamps
|
|
// to the start of the crab rather than splitting the codepoint.
|
|
assert_eq!(char_to_byte(line, 1, PositionEncoding::Utf16), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn nth_line_picks_the_right_slice() {
|
|
let doc = "abc\nré\n\nx";
|
|
assert_eq!(nth_line(doc, 0), Some("abc"));
|
|
assert_eq!(nth_line(doc, 1), Some("ré"));
|
|
assert_eq!(nth_line(doc, 2), Some("")); // genuinely empty line
|
|
assert_eq!(nth_line(doc, 3), Some("x"));
|
|
assert_eq!(nth_line(doc, 9), None); // past EOF ⇒ leave unconverted
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_positions_recurses_ranges_and_skips_utf8() {
|
|
let doc = "é=x"; // line 0
|
|
// A definition-shaped Location[]: Range start/end are Positions.
|
|
let mut v = json!([{
|
|
"uri": "file:///x",
|
|
"range": {
|
|
"start": { "line": 0, "character": 1 }, // utf16 '=' → byte 2
|
|
"end": { "line": 0, "character": 2 } // utf16 'x' → byte 3
|
|
}
|
|
}]);
|
|
rewrite_positions_to_bytes(&mut v, doc, PositionEncoding::Utf16);
|
|
let r = &v[0]["range"];
|
|
assert_eq!(r["start"]["character"], json!(2));
|
|
assert_eq!(r["end"]["character"], json!(3));
|
|
assert_eq!(r["start"]["line"], json!(0)); // line untouched
|
|
|
|
// UTF-8 fast path: structurally identical input is unchanged.
|
|
let mut v2 = json!({ "line": 0, "character": 1 });
|
|
rewrite_positions_to_bytes(&mut v2, doc, PositionEncoding::Utf8);
|
|
assert_eq!(v2["character"], json!(1));
|
|
}
|
|
|
|
// ---- T M4.5: workspace/configuration section resolution --------------
|
|
|
|
#[test]
|
|
fn resolve_config_section_semantics() {
|
|
let s = json!({
|
|
"python": { "analysis": { "typeCheckingMode": "basic" } },
|
|
"x": 1,
|
|
"nullable": null,
|
|
});
|
|
// Dotted path walks nested objects.
|
|
assert_eq!(
|
|
resolve_config_section(&s, Some("python.analysis.typeCheckingMode")),
|
|
json!("basic")
|
|
);
|
|
assert_eq!(
|
|
resolve_config_section(&s, Some("python.analysis")),
|
|
json!({ "typeCheckingMode": "basic" })
|
|
);
|
|
assert_eq!(resolve_config_section(&s, Some("x")), json!(1));
|
|
// A configured `null` is returned as-is (distinct from unknown).
|
|
assert_eq!(resolve_config_section(&s, Some("nullable")), Value::Null);
|
|
// Unknown section ⇒ null (the spec's "not configured" signal).
|
|
assert_eq!(
|
|
resolve_config_section(&s, Some("python.missing")),
|
|
Value::Null
|
|
);
|
|
assert_eq!(resolve_config_section(&s, Some("nope")), Value::Null);
|
|
// Absent / empty section ⇒ the whole settings object.
|
|
assert_eq!(resolve_config_section(&s, None), s);
|
|
assert_eq!(resolve_config_section(&s, Some("")), s);
|
|
}
|
|
}
|