hjkl-lsp 0.40.0

LSP client for the hjkl modal editor.
Documentation
//! `LspManager` — the sync-side handle to the async LSP runtime thread.

use std::path::Path;
use std::sync::Arc;

use crossbeam_channel::Receiver;
use tokio::sync::mpsc::UnboundedSender;

use crate::BufferId;
use crate::config::LspConfig;
use crate::event::{LspCommand, LspEvent, TextChange};
use crate::runtime;

/// Owned handle to the background LSP thread and its channels.
///
/// Drop calls `ShutdownAll` but does **not** join the thread. Call
/// [`LspManager::shutdown`] for a clean, blocking teardown.
pub struct LspManager {
    cmd_tx: UnboundedSender<LspCommand>,
    evt_rx: Receiver<LspEvent>,
    thread: Option<std::thread::JoinHandle<()>>,
}

impl LspManager {
    /// Spawn the background "hjkl-lsp" thread with its own `current_thread`
    /// tokio runtime. Returns immediately.
    pub fn spawn(config: LspConfig) -> Self {
        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<LspCommand>();
        let (evt_tx, evt_rx) = crossbeam_channel::unbounded::<LspEvent>();

        let thread = std::thread::Builder::new()
            .name("hjkl-lsp".to_string())
            .spawn(move || {
                let rt = tokio::runtime::Builder::new_current_thread()
                    .enable_all()
                    .build()
                    .expect("failed to build LSP tokio runtime");
                rt.block_on(runtime::dispatch(cmd_rx, evt_tx, config));
            })
            .expect("failed to spawn hjkl-lsp thread");

        Self {
            cmd_tx,
            evt_rx,
            thread: Some(thread),
        }
    }

    /// Gracefully shut down: sends `ShutdownAll`, joins the thread (≤ 2 s).
    ///
    /// If the LSP background thread hangs past the 2 s deadline, the helper
    /// thread becomes an intentionally detached OS thread until process exit
    /// (audit M8). This is bounded: one thread per shutdown call, typically
    /// once per process lifetime. A proper timed join on `JoinHandle` would
    /// require nightly `JoinHandleExt::join_timeout` or a full `pthread_timedjoin`.
    pub fn shutdown(mut self) {
        let _ = self.cmd_tx.send(LspCommand::ShutdownAll);
        if let Some(handle) = self.thread.take() {
            // Park the current thread with a 2-second timeout.
            // `std::thread::JoinHandle` doesn't have a timed join in stable Rust,
            // so we use a helper thread to enforce the deadline.
            // If `handle.join()` takes > 2 s the helper is intentionally
            // detached — the OS reclaims it at process exit. (M8)
            let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
            std::thread::spawn(move || {
                let _ = handle.join();
                let _ = done_tx.send(());
            });
            let _ = done_rx.recv_timeout(std::time::Duration::from_secs(2));
        }
    }

    /// Send a command to the background LSP thread, warning (once per
    /// call) instead of silently swallowing the failure when the channel
    /// is closed — i.e. the background thread has died. Without this, a
    /// dead `hjkl-lsp` thread makes every command a permanent, invisible
    /// no-op (audit D7). `label` is the command's variant name, logged
    /// instead of the command itself since some variants (`NotifyChange`,
    /// `AttachBuffer`) carry a whole buffer's text.
    fn send_cmd(&self, label: &'static str, cmd: LspCommand) {
        if self.cmd_tx.send(cmd).is_err() {
            tracing::warn!(
                command = label,
                "hjkl-lsp: command dropped — background thread is not running"
            );
        }
    }

    /// Tell the runtime an LSP process exited so dead server state is removed.
    pub fn server_exited(&self, key: crate::event::ServerKey) {
        self.send_cmd("ServerExited", LspCommand::ServerExited { key });
    }

    /// Attach a buffer to the appropriate language server.
    pub fn attach_buffer(&self, id: BufferId, path: &Path, language_id: &str, text: &str) {
        self.send_cmd(
            "AttachBuffer",
            LspCommand::AttachBuffer {
                id,
                path: path.to_path_buf(),
                language_id: language_id.to_string(),
                text: text.to_string(),
            },
        );
    }

    /// Detach a buffer (closes the document on the server side).
    pub fn detach_buffer(&self, id: BufferId) {
        self.send_cmd("DetachBuffer", LspCommand::DetachBuffer { id });
    }

    /// Notify the server that a buffer's full text changed.
    ///
    /// Takes an `Arc<String>` so the caller can hand off the shared
    /// `View::content_joined()` cache without a `to_string()` clone of the
    /// entire buffer per keystroke (on a 1.86 M-line file the clone was
    /// ~22 % of per-keystroke CPU in profiling).
    pub fn notify_change(&self, id: BufferId, full_text: Arc<String>) {
        self.send_cmd("NotifyChange", LspCommand::NotifyChange { id, full_text });
    }

    /// Notify the server of an incremental edit batch. Each `TextChange` is
    /// a `{range, text}` LSP `TextDocumentContentChangeEvent`. Positions
    /// must already match the server's negotiated `positionEncoding` —
    /// the caller is responsible for converting from UTF-8 byte columns to
    /// UTF-16 code units when the server didn't negotiate UTF-8.
    pub fn notify_change_incremental(&self, id: BufferId, changes: Vec<TextChange>) {
        self.send_cmd(
            "NotifyChangeIncremental",
            LspCommand::NotifyChangeIncremental { id, changes },
        );
    }

    /// Notify the server that a buffer was saved (`textDocument/didSave`).
    /// rust-analyzer (and most servers) run their flycheck — `cargo check` /
    /// `cargo clippy` — on save, so this is what surfaces clippy diagnostics.
    pub fn notify_save(&self, id: BufferId) {
        self.send_cmd("NotifySave", LspCommand::NotifySave { id });
    }

    /// Send a JSON-RPC request to the server attached to `buffer_id`.
    ///
    /// `request_id` is app-allocated — it will appear in
    /// `LspEvent::Response { request_id, .. }` when the response arrives.
    pub fn send_request(
        &self,
        request_id: i64,
        buffer_id: BufferId,
        method: &str,
        params: serde_json::Value,
    ) {
        self.send_cmd(
            "Request",
            LspCommand::Request {
                request_id,
                buffer_id,
                method: method.to_string(),
                params,
            },
        );
    }

    /// Non-blocking poll: returns the next pending event, or `None` if empty.
    pub fn try_recv_event(&self) -> Option<LspEvent> {
        self.evt_rx.try_recv().ok()
    }
}

impl Drop for LspManager {
    fn drop(&mut self) {
        let _ = self.cmd_tx.send(LspCommand::ShutdownAll);
        // Don't join in Drop — caller should call shutdown() explicitly.
        // If the thread is still around, just detach (JoinHandle drop detaches).
    }
}