use super::super::super::picker::ProjectStatus;
use super::super::attach::AttachRecord;
use super::super::*;
use super::layer_suffix;
use std::sync::mpsc::channel;
impl Editor {
pub(crate) fn handle_lsp_attach(&mut self, record: AttachRecord) {
trace_attach(&record);
self.worker_handles.remove(&record.ticket);
let key = self
.lsp_state
.attach
.pending
.iter()
.find_map(|(key, &owner)| (owner == record.ticket).then(|| key.clone()));
let Some(key) = key else {
trace::services::rejected("lsp", "attach completion superseded");
self.retire_superseded_transport(record.server);
return;
};
self.lsp_state.attach.pending.remove(&key);
let warm = self.lsp_state.attach.warm_attempts.remove(&key);
if key.target != record.target || key.language != record.language {
self.retire_superseded_transport(record.server);
trace::services::rejected("lsp", "attach result differs from its requested workspace");
return;
}
let attach::AttachRecord {
ticket: _,
server,
language,
name,
root,
target,
outcome,
layers,
} = record;
self.record_layer_diagnostics(&layers);
let warning = layer_suffix(&layers);
match outcome {
attach::AttachDecision::Cancelled => {}
attach::AttachDecision::Attached => {
if warm {
self.record_project_status(key.path.clone(), ProjectStatus::healthy());
}
let Some(server) = server else { return };
if self.lsp_state.attach.attached.iter().any(|attachment| {
attachment.language == language
&& attachment.root == root
&& attachment.target == target
}) {
self.retire_superseded_transport(Some(server));
self.lsp_did_open_document(self.current());
return;
}
self.lsp_state
.attach
.attached
.retain(|a| !(a.language == language && a.root == root && a.target == target));
self.lsp_state.attach.attached.push(attach::Attachment {
language: language.clone(),
root: root.clone(),
server,
target: target.clone(),
});
match warning {
Some(warning) => self.message = format!("lsp: {warning}"),
None => self.message = format!("lsp: {name} starting"),
}
let transport = self
.lsp_state
.attach
.transport
.lock()
.ok()
.and_then(|mut table| table.remove(&server));
match transport {
Some(attach::LiveTransport { client, rx }) => {
if let Some(app_tx) = &self.app_tx {
let tx = app_tx.clone();
std::thread::spawn(move || {
while let Ok(event) = rx.recv() {
if tx
.send_blocking(crate::editor::events::AppEvent::Lsp(event))
.is_err()
{
break;
}
}
});
let (_, empty) = channel();
self.lsp_servers.push(LspServer {
id: server,
client: Some(client),
rx: empty,
ready: false,
});
} else {
self.lsp_servers.push(LspServer {
id: server,
client: Some(client),
rx,
ready: false,
});
}
}
None => {
self.lsp_servers.push(LspServer {
id: server,
client: None,
rx: channel().1,
ready: false,
});
}
}
self.lsp_did_open_document(self.current());
}
decision => {
if warm {
self.record_project_status(
key.path.clone(),
ProjectStatus::from_decision(&decision, &name),
);
}
if matches!(
decision,
attach::AttachDecision::TrustRequired { .. }
| attach::AttachDecision::TrustError { .. }
) {
self.lsp_state
.attach
.trust_roots
.insert(key.clone(), root.clone());
}
let sticky = !matches!(
decision,
attach::AttachDecision::TrustRequired { .. }
| attach::AttachDecision::TrustError { .. }
| attach::AttachDecision::RemoteIo { .. }
);
let first = self
.lsp_state
.attach
.refused
.insert(key, decision.clone())
.is_none();
if sticky && !first {
return;
}
self.message = match decision {
attach::AttachDecision::NoServer => format!("no language server for {name}"),
attach::AttachDecision::TrustRequired { command } => {
format!("project config wants to run `{command}` — :trust to allow (once)")
}
attach::AttachDecision::TrustError { error } => {
format!("project trust: {error}")
}
attach::AttachDecision::NotExecutable {
command,
reason,
hint,
} => {
format!("lsp: {command} {reason} — {hint}")
}
attach::AttachDecision::SpawnFailed { reason } => {
format!("lsp: {name} could not start — {reason}")
}
attach::AttachDecision::RemoteIo { reason } => {
format!("lsp: remote discovery failed — {reason}")
}
attach::AttachDecision::Attached | attach::AttachDecision::Cancelled => {
unreachable!("matched above")
}
};
if let Some(warning) = warning {
self.message = format!("{} — {}", self.message, warning);
}
}
}
self.lsp_warm_progress();
}
fn record_layer_diagnostics(&mut self, layers: &[strop_lsp::languages::LayerDiagnostic]) {
for diagnostic in layers {
let state = &mut self.lsp_state.attach;
if !state.layer_diagnostics.contains(diagnostic) {
state.layer_diagnostics.push(diagnostic.clone());
}
}
}
fn retire_superseded_transport(&mut self, server: Option<ServerId>) {
let Some(server) = server else { return };
let transport = self
.lsp_state
.attach
.transport
.lock()
.ok()
.and_then(|mut table| table.remove(&server));
if let Some(attach::LiveTransport { client, .. }) = transport {
std::thread::spawn(move || {
client.shutdown();
client.wait(std::time::Duration::from_secs(2));
});
}
}
}
fn trace_attach(record: &attach::AttachRecord) {
use strop_trace::{record_with, EventKind};
record_with(EventKind::JobFinished, || {
let mut value = serde_json::json!({
"service": "lsp",
"result": "attach",
"outcome": record.outcome.label(),
"language": record.language,
"name": record.name,
"server": record.server,
"target": record.target.label(),
"root": trace::services::NativePath(record.root.clone()),
"layers": &record.layers,
});
match &record.outcome {
attach::AttachDecision::NotExecutable {
command,
reason,
hint,
} => {
value["command"] = serde_json::json!(command);
value["reason"] = serde_json::json!(reason);
value["hint"] = serde_json::json!(hint);
}
attach::AttachDecision::SpawnFailed { reason }
| attach::AttachDecision::RemoteIo { reason } => {
value["reason"] = serde_json::json!(reason);
}
_ => {}
}
value
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::editor::events::{AppEvent, MAX_SEMANTIC_EVENTS};
use std::time::Duration;
use strop_core::{worker::WorkerId, Buffer};
use strop_lsp::{registry::ServerSpec, Client, LspEvent, Workspace};
#[test]
fn late_ready_and_failed_events_survive_a_full_semantic_lane() {
let mut editor = Editor::new(Buffer::from_text("x\n"));
let root = editor.cwd.clone();
let spec = ServerSpec {
name: "rust-analyzer",
command: "strop-test-no-such-lsp-server",
args: &[],
install_hint: None,
init_options: None,
project_executable: false,
};
let (client_tx, _client_rx) = channel();
let client = Client::spawn(
&spec,
Workspace::Local { root: root.clone() },
client_tx,
Some(editor.filesystem.worker().clone()),
)
.expect("the late-attaching client fixture starts");
let server = client.id();
let (server_tx, server_rx) = channel();
editor.lsp_state.attach.transport.lock().unwrap().insert(
server,
attach::LiveTransport {
client,
rx: server_rx,
},
);
let ticket = WorkerId::new(1);
editor.lsp_state.attach.pending.insert(
attach::AttachKey {
target: strop_workspace::Filesystem::Local,
language: "rust".into(),
path: root.join("main.rs"),
},
ticket,
);
let (tx, rx) = crate::editor::events::channel();
editor.app_tx = Some(tx.clone());
editor.handle_lsp_attach(AttachRecord {
ticket,
server: Some(server),
language: "rust".into(),
name: "rust-analyzer".into(),
root,
target: strop_workspace::Filesystem::Local,
outcome: attach::AttachDecision::Attached,
layers: Vec::new(),
});
assert_eq!(editor.lsp_servers.len(), 1);
for _ in 0..MAX_SEMANTIC_EVENTS {
tx.send(AppEvent::QuitIntent).unwrap();
}
assert!(tx.send(AppEvent::QuitIntent).is_err());
server_tx
.send(LspEvent::Ready {
server,
name: "rust-analyzer".into(),
})
.unwrap();
server_tx
.send(LspEvent::Failed {
server,
name: "rust-analyzer".into(),
hint: "install rust-analyzer".into(),
})
.unwrap();
drop(server_tx);
std::thread::yield_now();
let mut terminal = Vec::new();
for index in 0..MAX_SEMANTIC_EVENTS + 2 {
let event = rx
.recv_timeout(Duration::from_secs(5))
.expect("late LSP terminal events must not be stranded");
if index < MAX_SEMANTIC_EVENTS {
assert!(matches!(event, AppEvent::QuitIntent));
} else {
terminal.push(event);
}
}
let mut terminal = terminal.into_iter();
let ready = terminal.next().unwrap();
let failed = terminal.next().unwrap();
assert!(matches!(
&ready,
AppEvent::Lsp(LspEvent::Ready { server: id, name })
if *id == server && name == "rust-analyzer"
));
assert!(matches!(
&failed,
AppEvent::Lsp(LspEvent::Failed { server: id, hint, .. })
if *id == server && hint == "install rust-analyzer"
));
editor.handle_app_event(ready);
assert!(editor.lsp_servers[0].ready);
editor.handle_app_event(failed);
assert!(editor.lsp_servers.is_empty());
}
}