pmacs/src/lsp_status.rs

1116 lines
38 KiB
Rust

// lsp_status.rs --- T M4.8 LSP status surface: state machine + log.
//! Per-server LSP status the modeline and `*lsp*` buffer read from.
//!
//! Per spec §M4.8: "state (initializing, ready, indexing, degraded,
//! crashed) visible at all times" and "last error retrievable via a
//! binding". This module folds the raw [`crate::lsp::LspEvent`]
//! stream into a stable, queryable [`LspStatus`].
//!
//! # State machine
//!
//! ```text
//! Initializing ─── Initialized event ──→ Ready
//! │ │
//! │ $/progress │ Indexing
//! │ begin/Indexing ─→│
//! │ │ end ←──┐
//! │ ↑ │
//! │ └────────┘
//! │
//! ├── ProtocolError / response.error ─→ Degraded { reason }
//! │ ↑ │
//! │ └─ further errors stay degraded; │ next Initialized
//! │ │ flips back to Ready
//! │
//! ├── Crashed event → Crashed { reason }
//! └── ShuttingDown / Stopped → Stopped
//! ```
//!
//! # Why a separate module
//!
//! [`crate::lsp::LspManager`] already tracks the low-level lifecycle
//! ([`crate::lsp::LspClientState`]). The status surface is a *higher*
//! abstraction — what the modeline says — and keeping it apart lets
//! the modeline consume one stable enum without re-deriving "ready
//! vs indexing" on every render.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use serde_json::Value;
use crate::lsp::{LspClientState, LspError, LspEvent, LspEventKind, LspServerId};
// ---------------------------------------------------------------------------
// Status kind
// ---------------------------------------------------------------------------
/// High-level status for one LSP server, consumed by the modeline /
/// `*lsp*` buffer / Lua surface.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LspStatusKind {
/// Process spawned, `initialize` request in flight (or just sent).
Initializing,
/// `initialize` response received; ready to serve requests.
Ready,
/// Server is doing background work (rust-analyzer indexing,
/// pyright analysing the project, etc.). Surfaces the current
/// `$/progress` `title` and `percentage` (if reported).
Indexing {
/// Human-readable progress title (e.g. "indexing").
title: String,
/// 0..=100 percent if reported, otherwise `None`.
percentage: Option<u32>,
},
/// Something went wrong but the server is still running. The
/// modeline shows the last error message; the next clean
/// transition (`Initialized`, `$/progress` `end`) flips it back.
Degraded {
/// One-line summary of why the server is degraded.
reason: String,
},
/// Server died unexpectedly. Stays sticky until a successful
/// restart re-emits `Initialized`.
Crashed {
/// Display-formatted exit/signal reason.
reason: String,
},
/// Server was asked to shut down or has cleanly exited. Terminal.
Stopped,
}
impl LspStatusKind {
/// Short modeline label (≤ 9 characters). `*lsp*` and the modeline
/// share this so the user sees one consistent name.
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::Initializing => "init",
Self::Ready => "ready",
Self::Indexing { .. } => "idx",
Self::Degraded { .. } => "degraded",
Self::Crashed { .. } => "crashed",
Self::Stopped => "stopped",
}
}
/// Stable kind tag used by the Lua surface and the `*lsp*` buffer
/// (e.g. `"ready"`, `"indexing"`, `"degraded"`). Distinct from
/// [`Self::label`] so the Lua side gets the long form.
#[must_use]
pub fn tag(&self) -> &'static str {
match self {
Self::Initializing => "initializing",
Self::Ready => "ready",
Self::Indexing { .. } => "indexing",
Self::Degraded { .. } => "degraded",
Self::Crashed { .. } => "crashed",
Self::Stopped => "stopped",
}
}
}
// ---------------------------------------------------------------------------
// Recent messages
// ---------------------------------------------------------------------------
/// Default ring-buffer capacity for [`LspStatus::recent_messages`].
/// Sized to hold the chatter from one `initialize` round-trip plus a
/// handful of follow-on notifications without recycling.
pub const DEFAULT_MESSAGE_CAPACITY: usize = 64;
/// One entry in the per-server recent-message ring. Distinct from
/// [`crate::lsp::LspEvent`] because the status log is *display-shaped*:
/// it carries a short summary plus an optional detail line, ready to
/// drop into the `*lsp*` buffer.
#[derive(Clone, Debug)]
pub struct LspStatusMessage {
/// Monotonic timestamp.
pub at: Instant,
/// Severity / channel of the line: one of `"info"`, `"warn"`,
/// `"error"`, `"stderr"`. Used for colouring and for filtering
/// the log.
pub channel: &'static str,
/// One-line summary suitable for the `*lsp*` buffer's first
/// column.
pub summary: String,
/// Optional follow-on detail (e.g. an error's `data` field).
pub detail: Option<String>,
}
// ---------------------------------------------------------------------------
// Per-server status
// ---------------------------------------------------------------------------
/// Per-server LSP status. Derived from the [`LspEvent`] stream by
/// [`LspStatusTracker`].
#[derive(Clone, Debug)]
pub struct LspStatus {
/// Current state machine bucket.
pub kind: LspStatusKind,
/// Most recent error observed for this server, if any. Sticky
/// until cleared via [`LspStatus::clear_last_error`] or another
/// error replaces it. Surfaces through `pmacs.lsp.last_error`.
pub last_error: Option<LspStatusError>,
/// When [`Self::kind`] last changed.
pub last_state_change: Instant,
/// Cumulative restart count for this server (mirrored from the
/// LSP layer). The status surface uses this for the
/// `Degraded` heuristic.
pub restarts: u32,
/// `serverInfo` field from the most recent `initialize`
/// response, if reported.
pub server_info: Option<LspServerInfo>,
/// Ring buffer of recent log lines.
pub recent_messages: Vec<LspStatusMessage>,
/// Capacity of [`Self::recent_messages`].
pub message_capacity: usize,
}
impl LspStatus {
/// Initial status for a freshly spawned server. Starts in
/// [`LspStatusKind::Initializing`] with no error and an empty log.
#[must_use]
pub fn new(now: Instant) -> Self {
Self {
kind: LspStatusKind::Initializing,
last_error: None,
last_state_change: now,
restarts: 0,
server_info: None,
recent_messages: Vec::with_capacity(DEFAULT_MESSAGE_CAPACITY),
message_capacity: DEFAULT_MESSAGE_CAPACITY,
}
}
/// Replace the kind, bumping `last_state_change` if it changed.
pub fn set_kind(&mut self, kind: LspStatusKind, now: Instant) {
if self.kind != kind {
self.kind = kind;
self.last_state_change = now;
}
}
/// Append a status-log line, dropping the oldest entry if the
/// ring is at capacity.
pub fn push_message(&mut self, m: LspStatusMessage) {
if self.recent_messages.len() >= self.message_capacity && !self.recent_messages.is_empty() {
self.recent_messages.remove(0);
}
self.recent_messages.push(m);
}
/// Drop the sticky last error.
pub fn clear_last_error(&mut self) {
self.last_error = None;
}
}
/// Sticky last-error record. Keeps just enough to repro the failure
/// without dragging the entire JSON-RPC error around.
#[derive(Clone, Debug)]
pub struct LspStatusError {
/// When the error was observed.
pub at: Instant,
/// One of `"protocol"`, `"crash"`, `"response"`. The Lua surface
/// surfaces this as a tag so callers can filter (e.g. ignore
/// stderr noise).
pub source: &'static str,
/// Display-formatted message.
pub message: String,
/// Optional numeric code (JSON-RPC error code for `"response"`,
/// signal/exit code for `"crash"`, none for `"protocol"`).
pub code: Option<i64>,
}
impl LspStatusError {
fn from_lsp_error(at: Instant, e: &LspError) -> Self {
Self {
at,
source: "response",
message: e.message.clone(),
code: Some(e.code),
}
}
}
/// Subset of LSP `serverInfo` we keep around for the status buffer.
#[derive(Clone, Debug)]
pub struct LspServerInfo {
/// `name` field.
pub name: String,
/// `version` field if present.
pub version: Option<String>,
}
impl LspServerInfo {
/// Parse from a `serverInfo` JSON object. Returns `None` for
/// non-object inputs.
#[must_use]
pub fn from_value(v: &Value) -> Option<Self> {
let name = v.get("name")?.as_str()?.to_owned();
let version = v.get("version").and_then(Value::as_str).map(str::to_owned);
Some(Self { name, version })
}
}
// ---------------------------------------------------------------------------
// Tracker
// ---------------------------------------------------------------------------
/// How long a `Degraded` state stays sticky after the last error.
/// Past this window, an otherwise-`Ready` server clears its degraded
/// flag automatically.
pub const DEGRADED_STICKY: Duration = Duration::from_secs(15);
/// Per-manager status tracker. Holds one [`LspStatus`] per known
/// server and folds events into it.
#[derive(Default)]
pub struct LspStatusTracker {
by_server: HashMap<LspServerId, LspStatus>,
}
impl LspStatusTracker {
/// Empty tracker.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Ensure a status entry exists for `sid`. Idempotent. Called by
/// the LSP manager on every spawn.
pub fn ensure(&mut self, sid: LspServerId, now: Instant) -> &mut LspStatus {
self.by_server
.entry(sid)
.or_insert_with(|| LspStatus::new(now))
}
/// Forget about `sid`. Mirrors [`crate::lsp::LspManager::forget`].
pub fn forget(&mut self, sid: LspServerId) {
self.by_server.remove(&sid);
}
/// Look up the current status for `sid`.
#[must_use]
pub fn get(&self, sid: LspServerId) -> Option<&LspStatus> {
self.by_server.get(&sid)
}
/// All known server ids (for the `*lsp*` buffer).
pub fn servers(&self) -> impl Iterator<Item = LspServerId> + '_ {
self.by_server.keys().copied()
}
/// Fold one [`LspEvent`] into `sid`'s status. `client_state` is
/// the LSP layer's *current* state for the server (the one that
/// would result from the same event sequence) — the tracker uses
/// it as a tiebreaker (e.g. so a `ProtocolError` while
/// `Initializing` keeps the modeline saying "init" rather than
/// "degraded").
#[allow(
clippy::too_many_lines,
reason = "linear dispatch over LspEventKind variants; splitting fragments the per-variant logic"
)]
pub fn observe(&mut self, ev: &LspEvent, client_state: Option<&LspClientState>) {
let st = self.ensure(ev.server, ev.at);
match &ev.kind {
LspEventKind::Started { pid } => {
st.set_kind(LspStatusKind::Initializing, ev.at);
st.push_message(LspStatusMessage {
at: ev.at,
channel: "info",
summary: format!("started (pid {pid})"),
detail: None,
});
}
LspEventKind::Initialized { capabilities } => {
let server_info = capabilities
.get("serverInfo")
.and_then(LspServerInfo::from_value);
if server_info.is_some() {
st.server_info = server_info;
}
st.set_kind(LspStatusKind::Ready, ev.at);
st.push_message(LspStatusMessage {
at: ev.at,
channel: "info",
summary: "initialized".into(),
detail: None,
});
}
LspEventKind::Notification { method, params } => {
if method == "$/progress" {
if let Some(update) = parse_progress(params) {
apply_progress(st, ev.at, &update);
}
} else if method == "window/logMessage" {
let (channel, text) = parse_log_message(params);
st.push_message(LspStatusMessage {
at: ev.at,
channel,
summary: text,
detail: None,
});
}
}
LspEventKind::Request { method, .. } => {
st.push_message(LspStatusMessage {
at: ev.at,
channel: "info",
summary: format!("server→client request: {method}"),
detail: None,
});
}
LspEventKind::Response { method, error, .. } => {
if let Some(err) = error {
st.last_error = Some(LspStatusError::from_lsp_error(ev.at, err));
st.set_kind(
LspStatusKind::Degraded {
reason: format!("{method}: {} (code {})", err.message, err.code),
},
ev.at,
);
st.push_message(LspStatusMessage {
at: ev.at,
channel: "error",
summary: format!("response error: {method}"),
detail: Some(err.message.clone()),
});
}
}
LspEventKind::ShuttingDown => {
st.set_kind(LspStatusKind::Stopped, ev.at);
st.push_message(LspStatusMessage {
at: ev.at,
channel: "info",
summary: "shutting down".into(),
detail: None,
});
}
LspEventKind::Stopped => {
st.set_kind(LspStatusKind::Stopped, ev.at);
st.push_message(LspStatusMessage {
at: ev.at,
channel: "info",
summary: "stopped".into(),
detail: None,
});
}
LspEventKind::Crashed { reason } => {
st.last_error = Some(LspStatusError {
at: ev.at,
source: "crash",
message: reason.clone(),
code: None,
});
st.set_kind(
LspStatusKind::Crashed {
reason: reason.clone(),
},
ev.at,
);
st.push_message(LspStatusMessage {
at: ev.at,
channel: "error",
summary: "crashed".into(),
detail: Some(reason.clone()),
});
}
LspEventKind::Restarting { attempt } => {
st.restarts = *attempt;
st.push_message(LspStatusMessage {
at: ev.at,
channel: "warn",
summary: format!("restarting (attempt {attempt})"),
detail: None,
});
}
LspEventKind::Stderr(bytes) => {
let text = String::from_utf8_lossy(bytes).into_owned();
for line in text.lines() {
if line.is_empty() {
continue;
}
st.push_message(LspStatusMessage {
at: ev.at,
channel: "stderr",
summary: line.to_owned(),
detail: None,
});
}
}
LspEventKind::ProtocolError { message } => {
st.last_error = Some(LspStatusError {
at: ev.at,
source: "protocol",
message: message.clone(),
code: None,
});
// While Initializing, a protocol error before the
// first `Initialized` keeps the modeline saying
// "init" — the LSP layer will surface a Crashed
// shortly if the server actually died. Otherwise
// flip to Degraded.
let initializing = matches!(
client_state,
Some(LspClientState::Starting | LspClientState::Initializing { .. })
);
if !initializing {
st.set_kind(
LspStatusKind::Degraded {
reason: message.clone(),
},
ev.at,
);
}
st.push_message(LspStatusMessage {
at: ev.at,
channel: "error",
summary: "protocol error".into(),
detail: Some(message.clone()),
});
}
}
}
/// Periodic housekeeping. Releases the sticky `Degraded` state
/// after [`DEGRADED_STICKY`] has elapsed since the last error,
/// returning the server to `Ready` provided the LSP layer still
/// reports it as initialised.
pub fn tick(
&mut self,
now: Instant,
mut current_state: impl FnMut(LspServerId) -> Option<LspClientState>,
) {
for (sid, st) in &mut self.by_server {
let LspStatusKind::Degraded { .. } = &st.kind else {
continue;
};
let stale = st
.last_error
.as_ref()
.is_none_or(|e| now.duration_since(e.at) >= DEGRADED_STICKY);
if !stale {
continue;
}
// Only flip back to Ready if the LSP layer agrees the
// server is still alive and initialised.
if matches!(
current_state(*sid),
Some(LspClientState::Initialized { .. })
) {
st.kind = LspStatusKind::Ready;
st.last_state_change = now;
}
}
}
}
// ---------------------------------------------------------------------------
// $/progress parsing
// ---------------------------------------------------------------------------
#[derive(Clone, Debug)]
struct ProgressUpdate {
kind: ProgressKind,
title: Option<String>,
percentage: Option<u32>,
message: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ProgressKind {
Begin,
Report,
End,
}
fn parse_progress(params: &Value) -> Option<ProgressUpdate> {
let value = params.get("value")?;
let kind = match value.get("kind").and_then(Value::as_str)? {
"begin" => ProgressKind::Begin,
"report" => ProgressKind::Report,
"end" => ProgressKind::End,
_ => return None,
};
let title = value
.get("title")
.and_then(Value::as_str)
.map(str::to_owned);
let percentage = value
.get("percentage")
.and_then(Value::as_u64)
.map(|n| n.min(100) as u32);
let message = value
.get("message")
.and_then(Value::as_str)
.map(str::to_owned);
Some(ProgressUpdate {
kind,
title,
percentage,
message,
})
}
fn apply_progress(st: &mut LspStatus, at: Instant, update: &ProgressUpdate) {
match update.kind {
ProgressKind::Begin | ProgressKind::Report => {
// Hold onto whatever title we already had if this update
// doesn't carry one (LSP `report` updates are allowed to
// drop the title).
let title = update
.title
.clone()
.or_else(|| match &st.kind {
LspStatusKind::Indexing { title, .. } => Some(title.clone()),
_ => None,
})
.unwrap_or_else(|| "indexing".into());
st.set_kind(
LspStatusKind::Indexing {
title,
percentage: update.percentage,
},
at,
);
if let Some(msg) = &update.message {
st.push_message(LspStatusMessage {
at,
channel: "info",
summary: format!("progress: {msg}"),
detail: None,
});
}
}
ProgressKind::End => {
// End → Ready (only if we were actually indexing, so an
// out-of-band `end` doesn't kick us out of `Degraded`).
if matches!(st.kind, LspStatusKind::Indexing { .. }) {
st.set_kind(LspStatusKind::Ready, at);
}
if let Some(msg) = &update.message {
st.push_message(LspStatusMessage {
at,
channel: "info",
summary: format!("progress done: {msg}"),
detail: None,
});
}
}
}
}
fn parse_log_message(params: &Value) -> (&'static str, String) {
#[allow(
clippy::match_same_arms,
reason = "explicit code-4 arm documents the LSP MessageType=Log mapping; the wildcard is also info"
)]
let channel = match params.get("type").and_then(Value::as_i64) {
Some(1) => "error",
Some(2) => "warn",
Some(4) => "info",
// 3 = info, 5 = debug, anything else → info.
_ => "info",
};
let message = params
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
(channel, message)
}
// ---------------------------------------------------------------------------
// `*lsp*` buffer formatting
// ---------------------------------------------------------------------------
/// Render the contents of the `*lsp*` status buffer. One section per
/// server with state, capabilities highlights, and the recent log.
///
/// Caller-supplied `label` resolver lets the formatter print the
/// human label the user gave at spawn time without reaching back
/// into the LSP layer for every server.
#[must_use]
pub fn format_status_buffer(
tracker: &LspStatusTracker,
label_for: impl Fn(LspServerId) -> Option<String>,
capabilities_for: impl Fn(LspServerId) -> Option<Value>,
now: Instant,
) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let mut servers: Vec<LspServerId> = tracker.servers().collect();
servers.sort_by_key(|s| s.raw());
if servers.is_empty() {
out.push_str("No active LSP servers.\n");
return out;
}
let _ = writeln!(out, "LSP servers: {}", servers.len());
out.push_str("=================================================\n\n");
for sid in servers {
let Some(st) = tracker.get(sid) else { continue };
let label = label_for(sid).unwrap_or_else(|| "<unnamed>".into());
let _ = writeln!(
out,
"[{label}] id={raw} state={tag}",
raw = sid.raw(),
tag = st.kind.tag()
);
if let LspStatusKind::Indexing { title, percentage } = &st.kind {
match percentage {
Some(p) => {
let _ = writeln!(out, " indexing: {title} ({p}%)");
}
None => {
let _ = writeln!(out, " indexing: {title}");
}
}
}
if let LspStatusKind::Degraded { reason } | LspStatusKind::Crashed { reason } = &st.kind {
let _ = writeln!(out, " reason: {reason}");
}
if st.restarts > 0 {
let _ = writeln!(out, " restarts: {}", st.restarts);
}
if let Some(info) = &st.server_info {
match info.version.as_deref() {
Some(v) => {
let _ = writeln!(out, " server: {} {}", info.name, v);
}
None => {
let _ = writeln!(out, " server: {}", info.name);
}
}
}
if let Some(err) = &st.last_error {
let age = now.saturating_duration_since(err.at);
let code = match err.code {
Some(c) => format!(" (code {c})"),
None => String::new(),
};
let _ = writeln!(
out,
" last error [{src}]{code}: {msg} ({secs}s ago)",
src = err.source,
msg = err.message,
secs = age.as_secs()
);
}
if let Some(caps) = capabilities_for(sid) {
let cap_keys = caps_summary(&caps);
if !cap_keys.is_empty() {
out.push_str(" capabilities: ");
out.push_str(&cap_keys.join(", "));
out.push('\n');
}
}
if !st.recent_messages.is_empty() {
out.push_str(" recent:\n");
let total = st.recent_messages.len();
let start = total.saturating_sub(8);
for m in &st.recent_messages[start..] {
let age = now.saturating_duration_since(m.at);
let _ = writeln!(
out,
" [{ch}] {summary} ({secs}s ago)",
ch = m.channel,
summary = m.summary,
secs = age.as_secs()
);
if let Some(d) = &m.detail {
let _ = writeln!(out, " └─ {d}");
}
}
}
out.push('\n');
}
out
}
/// Pull a short list of "interesting" capability flags from a JSON
/// `ServerCapabilities` object. Used by [`format_status_buffer`].
fn caps_summary(caps: &Value) -> Vec<&'static str> {
let mut out = Vec::new();
let has = |k: &str| caps.get(k).is_some();
if has("textDocumentSync") || has("textDocumentSyncKind") {
out.push("sync");
}
if has("hoverProvider") {
out.push("hover");
}
if has("completionProvider") {
out.push("completion");
}
if has("signatureHelpProvider") || has("signatureHelp") {
out.push("signature");
}
if has("definitionProvider") {
out.push("definition");
}
if has("referencesProvider") {
out.push("references");
}
if has("documentSymbolProvider") {
out.push("symbols");
}
if has("diagnosticProvider") {
out.push("diagnostics");
}
if has("renameProvider") {
out.push("rename");
}
out
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn ev(server: LspServerId, kind: LspEventKind, at: Instant) -> LspEvent {
LspEvent { server, kind, at }
}
#[test]
fn label_and_tag_for_each_kind() {
for (k, label, tag) in [
(LspStatusKind::Initializing, "init", "initializing"),
(LspStatusKind::Ready, "ready", "ready"),
(
LspStatusKind::Indexing {
title: "x".into(),
percentage: Some(10),
},
"idx",
"indexing",
),
(
LspStatusKind::Degraded { reason: "x".into() },
"degraded",
"degraded",
),
(
LspStatusKind::Crashed { reason: "x".into() },
"crashed",
"crashed",
),
(LspStatusKind::Stopped, "stopped", "stopped"),
] {
assert_eq!(k.label(), label);
assert_eq!(k.tag(), tag);
}
}
#[test]
fn observe_started_then_initialized_transitions_to_ready() {
let mut t = LspStatusTracker::new();
let sid = LspServerId::next();
let now = Instant::now();
t.observe(&ev(sid, LspEventKind::Started { pid: 42 }, now), None);
assert_eq!(t.get(sid).unwrap().kind, LspStatusKind::Initializing);
t.observe(
&ev(
sid,
LspEventKind::Initialized {
capabilities: json!({"serverInfo": {"name": "x", "version": "1"}}),
},
now,
),
None,
);
assert_eq!(t.get(sid).unwrap().kind, LspStatusKind::Ready);
assert_eq!(t.get(sid).unwrap().server_info.as_ref().unwrap().name, "x");
}
#[test]
fn protocol_error_during_initializing_does_not_flip_to_degraded() {
let mut t = LspStatusTracker::new();
let sid = LspServerId::next();
let now = Instant::now();
t.observe(&ev(sid, LspEventKind::Started { pid: 1 }, now), None);
t.observe(
&ev(
sid,
LspEventKind::ProtocolError {
message: "garbage frame".into(),
},
now,
),
Some(&LspClientState::Initializing {
init_request_id: 1,
started: now,
}),
);
assert_eq!(t.get(sid).unwrap().kind, LspStatusKind::Initializing);
assert!(t.get(sid).unwrap().last_error.is_some());
}
#[test]
fn protocol_error_after_ready_flips_to_degraded() {
let mut t = LspStatusTracker::new();
let sid = LspServerId::next();
let now = Instant::now();
t.observe(
&ev(
sid,
LspEventKind::Initialized {
capabilities: json!({}),
},
now,
),
None,
);
t.observe(
&ev(
sid,
LspEventKind::ProtocolError {
message: "bad frame".into(),
},
now,
),
Some(&LspClientState::Initialized {
capabilities: json!({}),
server_info: None,
initialized_at: now,
}),
);
let st = t.get(sid).unwrap();
assert!(matches!(st.kind, LspStatusKind::Degraded { .. }));
assert_eq!(st.last_error.as_ref().unwrap().source, "protocol");
}
#[test]
fn progress_begin_to_end_cycles_indexing() {
let mut t = LspStatusTracker::new();
let sid = LspServerId::next();
let now = Instant::now();
// Get to Ready first.
t.observe(
&ev(
sid,
LspEventKind::Initialized {
capabilities: json!({}),
},
now,
),
None,
);
// begin
t.observe(
&ev(
sid,
LspEventKind::Notification {
method: "$/progress".into(),
params: json!({
"token": 1,
"value": { "kind": "begin", "title": "indexing", "percentage": 5 }
}),
},
now,
),
None,
);
let kind = t.get(sid).unwrap().kind.clone();
assert!(matches!(kind, LspStatusKind::Indexing { ref title, .. } if title == "indexing"));
// report
t.observe(
&ev(
sid,
LspEventKind::Notification {
method: "$/progress".into(),
params: json!({
"token": 1,
"value": { "kind": "report", "percentage": 50 }
}),
},
now,
),
None,
);
if let LspStatusKind::Indexing { percentage, title } = &t.get(sid).unwrap().kind {
assert_eq!(*percentage, Some(50));
// Title from begin must persist across report w/o title.
assert_eq!(title, "indexing");
} else {
panic!("expected Indexing");
}
// end → Ready
t.observe(
&ev(
sid,
LspEventKind::Notification {
method: "$/progress".into(),
params: json!({ "token": 1, "value": { "kind": "end" } }),
},
now,
),
None,
);
assert_eq!(t.get(sid).unwrap().kind, LspStatusKind::Ready);
}
#[test]
fn crashed_event_records_last_error_and_kind() {
let mut t = LspStatusTracker::new();
let sid = LspServerId::next();
let now = Instant::now();
t.observe(
&ev(
sid,
LspEventKind::Crashed {
reason: "exit 7".into(),
},
now,
),
None,
);
let st = t.get(sid).unwrap();
assert!(matches!(st.kind, LspStatusKind::Crashed { .. }));
let err = st.last_error.as_ref().unwrap();
assert_eq!(err.source, "crash");
assert_eq!(err.message, "exit 7");
}
#[test]
fn stderr_lines_split_on_newlines() {
let mut t = LspStatusTracker::new();
let sid = LspServerId::next();
let now = Instant::now();
t.observe(
&ev(
sid,
LspEventKind::Stderr(b"first\nsecond\n\nthird\n".to_vec()),
now,
),
None,
);
let lines: Vec<_> = t
.get(sid)
.unwrap()
.recent_messages
.iter()
.filter(|m| m.channel == "stderr")
.map(|m| m.summary.clone())
.collect();
assert_eq!(lines, vec!["first", "second", "third"]);
}
#[test]
fn ring_buffer_drops_oldest() {
let mut t = LspStatusTracker::new();
let sid = LspServerId::next();
let now = Instant::now();
// Force a small ring.
let st = t.ensure(sid, now);
st.message_capacity = 3;
for i in 0..5 {
t.observe(&ev(sid, LspEventKind::Started { pid: i }, now), None);
}
let st = t.get(sid).unwrap();
assert_eq!(st.recent_messages.len(), 3);
// Last message records the most recent pid (4).
assert!(st.recent_messages.last().unwrap().summary.contains("pid 4"));
}
#[test]
fn caps_summary_keys() {
let v = json!({
"textDocumentSync": 1,
"hoverProvider": true,
"completionProvider": { "triggerCharacters": ["."] },
"definitionProvider": true,
"diagnosticProvider": {}
});
let keys = caps_summary(&v);
assert!(keys.contains(&"sync"));
assert!(keys.contains(&"hover"));
assert!(keys.contains(&"completion"));
assert!(keys.contains(&"definition"));
assert!(keys.contains(&"diagnostics"));
assert!(!keys.contains(&"rename"));
}
#[test]
fn format_status_buffer_includes_state_and_capabilities() {
let mut t = LspStatusTracker::new();
let sid = LspServerId::next();
let now = Instant::now();
t.observe(
&ev(
sid,
LspEventKind::Initialized {
capabilities: json!({"hoverProvider": true, "serverInfo": {"name": "x"}}),
},
now,
),
None,
);
let s = format_status_buffer(
&t,
|id| Some(format!("srv-{}", id.raw())),
|_| Some(json!({"hoverProvider": true})),
now,
);
assert!(s.contains("LSP servers"));
assert!(s.contains("state=ready"));
assert!(s.contains("hover"));
}
#[test]
fn format_status_buffer_empty_branch() {
let t = LspStatusTracker::new();
let s = format_status_buffer(&t, |_| None, |_| None, Instant::now());
assert!(s.contains("No active LSP servers"));
}
#[test]
fn tick_releases_stale_degraded_when_layer_says_initialized() {
let mut t = LspStatusTracker::new();
let sid = LspServerId::next();
let now = Instant::now();
t.observe(
&ev(
sid,
LspEventKind::Initialized {
capabilities: json!({}),
},
now,
),
None,
);
t.observe(
&ev(
sid,
LspEventKind::ProtocolError {
message: "bad".into(),
},
now,
),
Some(&LspClientState::Initialized {
capabilities: json!({}),
server_info: None,
initialized_at: now,
}),
);
assert!(matches!(
t.get(sid).unwrap().kind,
LspStatusKind::Degraded { .. }
));
let later = now + DEGRADED_STICKY + Duration::from_millis(1);
t.tick(later, |_| {
Some(LspClientState::Initialized {
capabilities: json!({}),
server_info: None,
initialized_at: now,
})
});
assert_eq!(t.get(sid).unwrap().kind, LspStatusKind::Ready);
}
}