Skip to main content

agentmux_srv/server/
lsp_handlers.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5
6use crate::backend::rpc::engine::WshRpcEngine;
7use crate::backend::base::expand_home_dir_safe;
8use crate::backend::rpc_types::{LspSendReq, LspStartReq, LspStartResult, LspStopReq};
9
10use super::AppState;
11
12pub fn register_lsp_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
13    // ── LSP RPCs ───────────────────────────────────────────────────
14    // Three handlers backing the editor pane's LSP integration:
15    //   * lspstart — spawn (or attach to) the server for a file
16    //   * lspsend  — forward an LSP JSON-RPC message to the server's stdin
17    //   * lspstop  — refcount-decrement; server exits when count hits 0
18    // Server-pushed notifications (publishDiagnostics, $/progress, …)
19    // arrive via WS event `lsp:message` from the supervisor's reader task.
20    // Spec: docs/specs/SPEC_EDITOR_LSP_AND_THEMES_2026-05-26.md
21
22    let lsp_supervisor_start = state.lsp_supervisor.clone();
23    engine.register_typed(
24        "lspstart",
25        move |cmd: LspStartReq, _ctx| {
26            let supervisor = lsp_supervisor_start.clone();
27            async move {
28                let expanded = expand_home_dir_safe(&cmd.file_path);
29                let workspace_root =
30                    crate::backend::lsp::workspace::detect_workspace_root(expanded.as_path());
31                let result = supervisor
32                    .start(crate::backend::lsp::StartArgs {
33                        language: cmd.language,
34                        workspace_root,
35                    })
36                    .await
37                    .map_err(|e| e.to_wire_string())?;
38                Ok(LspStartResult {
39                    server_id: result.server_id,
40                    workspace_root: result.workspace_root,
41                })
42            }
43        },
44    );
45
46    let lsp_supervisor_send = state.lsp_supervisor.clone();
47    engine.register_typed(
48        "lspsend",
49        move |cmd: LspSendReq, _ctx| {
50            let supervisor = lsp_supervisor_send.clone();
51            async move {
52                let message_json = serde_json::to_string(&cmd.message)
53                    .map_err(|e| format!("lspsend serialize: {e}"))?;
54                supervisor
55                    .send(&cmd.server_id, &message_json)
56                    .await
57                    .map_err(|e| e.to_wire_string())?;
58                Ok(())
59            }
60        },
61    );
62
63    let lsp_supervisor_stop = state.lsp_supervisor.clone();
64    engine.register_typed(
65        "lspstop",
66        move |cmd: LspStopReq, _ctx| {
67            let supervisor = lsp_supervisor_stop.clone();
68            async move {
69                supervisor
70                    .stop(&cmd.server_id)
71                    .await
72                    .map_err(|e| e.to_wire_string())?;
73                Ok(())
74            }
75        },
76    );
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::backend::rpc_types::RpcMessage;
83
84    /// All three LSP commands record their request and response types.
85    ///
86    /// Before this, each handler deserialized into a `Cmd` struct declared
87    /// inside its own closure. Those cannot be named, so they could not be
88    /// generated from and the frontend restated every one of them by hand --
89    /// three shapes with nothing connecting them to the Rust they describe.
90    #[tokio::test]
91    async fn register_typed_records_the_three_lsp_commands() {
92        let state = crate::server::tests::test_state();
93        let (engine, _rx) = WshRpcEngine::new();
94        register_lsp_handlers(&engine, &state);
95        let schema = engine.schema_json();
96        let rows = schema.as_array().unwrap();
97        let find = |cmd: &str| {
98            rows.iter()
99                .find(|r| r["command"] == cmd)
100                .unwrap_or_else(|| panic!("{cmd} missing from the schema"))
101        };
102
103        let start = find("lspstart");
104        assert_eq!(start["requestName"], "LspStartReq");
105        assert_eq!(start["responseName"], "LspStartResult");
106
107        // `lspsend` and `lspstop` answer nothing, which is a real response
108        // type rather than an absent one -- the stubs say `Promise<void>` and
109        // the registry should agree rather than recording `Value`.
110        for cmd in ["lspsend", "lspstop"] {
111            let row = find(cmd);
112            assert_eq!(row["responseName"], "()", "{cmd} should answer nothing");
113        }
114        assert_eq!(find("lspsend")["requestName"], "LspSendReq");
115        assert_eq!(find("lspstop")["requestName"], "LspStopReq");
116    }
117
118    /// `lspsend.message` is an opaque passthrough: the backend re-serializes
119    /// it and writes it to the server's stdin without inspecting it. Anything
120    /// that is valid JSON has to survive, including the shapes a narrower
121    /// Rust type would have rejected -- which is why it stays a `Value` and
122    /// generates as `unknown` rather than as a named message type.
123    #[test]
124    fn lspsend_accepts_any_json_message() {
125        for message in [
126            serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize" }),
127            serde_json::json!([1, 2, 3]),
128            serde_json::json!("a bare string"),
129            serde_json::Value::Null,
130        ] {
131            let req: LspSendReq = serde_json::from_value(serde_json::json!({
132                "server_id": "srv-1",
133                "message": message.clone(),
134            }))
135            .unwrap_or_else(|e| panic!("{message} should deserialize, got {e}"));
136            assert_eq!(req.message, message, "the proxy must not reshape the payload");
137        }
138    }
139
140    /// A missing `server_id` is an error rather than a silent no-op. The
141    /// untyped handlers answered `lspstop: missing field ...`; `register_typed`
142    /// rejects it before the handler body runs, so the request never reaches
143    /// the supervisor either way.
144    #[tokio::test]
145    async fn a_malformed_request_is_rejected_rather_than_run() {
146        let state = crate::server::tests::test_state();
147        let (engine, mut rx) = WshRpcEngine::new();
148        register_lsp_handlers(&engine, &state);
149
150        engine.handle_message(RpcMessage {
151            command: "lspstop".to_string(),
152            reqid: "req-1".to_string(),
153            data: Some(serde_json::json!({})),
154            ..Default::default()
155        });
156        let resp = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
157            .await
158            .unwrap()
159            .unwrap();
160        assert!(
161            resp.error.contains("server_id"),
162            "expected a missing-field error naming the field, got {:?}",
163            resp.error,
164        );
165    }
166}