strop-engine 0.46.0

strop editor engine: documents, grammar, services and sessions — no frontend UI dependencies
//! Attach completion handling: outcome publication, refusal and sticky
//! bookkeeping, per-project warm status rows, and the status-line
//! message for every decision — silence is not a report.
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);
        // Warm-up completions feed per-project status rows (0063 §2);
        // document attach completions already have the status line.
        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;
        // Malformed layers are diagnosed whatever the outcome (0033
        // §2): a healthy fallback server must not erase them.
        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));
                // The placement exists before any didOpen resolves
                // against it — live and replayed alike.
                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 }) => {
                        // TUI: forward like every late-attaching server.
                        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 => {
                        // Replayed server: identity only, replies arrive
                        // through the injected event stream.
                        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);
                }
            }
        }
        // A completion freed a warm-up slot: keep the bounded queue
        // moving (0063 §2).
        self.lsp_warm_progress();
    }

    /// Record newly reported malformed-layer diagnostics (0033 §2),
    /// deduped: every later attach for the same layers is already
    /// covered.
    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 {
            // Joining never blocks the input thread (same policy as
            // lsp_failed).
            std::thread::spawn(move || {
                client.shutdown();
                client.wait(std::time::Duration::from_secs(2));
            });
        }
    }
}

/// Attach completions reach the structured trace with their outcome
/// and any malformed-layer diagnostics (0033 §2/§3) — silence is not a
/// report. Runs at handler entry, before ownership decisions.
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);
        // Let the late forwarder attempt while the lane is still full. An
        // honest producer parks; draining below releases it without sleeps.
        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());
    }
}