crates/cli/ents-lens/src/server.rs
server.rshistorycomment on this file
| 1 | //! The stdio Language Server Protocol adapter (`lens.serve`): a thin, |
| 2 | //! synchronous dispatch loop over `lsp-server` that forwards each request to |
| 3 | //! a [`Lens`] method and turns the [`Outcome`] back into LSP messages. |
| 4 | //! |
| 5 | //! All derivation lives in [`Lens`]; this module only frames JSON-RPC, |
| 6 | //! declares capabilities, and routes. It binds no socket and adds no git |
| 7 | //! transport (`lens.serve`) — the only IO is stdin/stdout. |
| 8 | //! |
| 9 | //! `lsp-server` (rust-analyzer's own scaffold) is synchronous, which suits |
| 10 | //! the lens exactly: every operation it performs — reading `refs/meta/*`, |
| 11 | //! diffing against the working tree, writing a signed commit — is blocking |
| 12 | //! git and filesystem work, so an async runtime would only wrap blocking |
| 13 | //! calls in `spawn_blocking` for no gain. The framing and pack of message |
| 14 | //! types come from the crate; nothing here hand-rolls JSON-RPC. |
| 15 | #![expect( |
| 16 | clippy::let_underscore_must_use, |
| 17 | reason = "sending on the LSP transport is best-effort: a closed pipe ends the loop on the \ |
| 18 | next receive, so a failed send needs no separate handling" |
| 19 | )] |
| 20 | |
| 21 | use gix_object::{Find, Write}; |
| 22 | use lsp_server::{Connection, ExtractError, Message, Request, RequestId, Response}; |
| 23 | use lsp_types::notification::{ |
| 24 | DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, DidSaveTextDocument, |
| 25 | Notification as _, PublishDiagnostics, |
| 26 | }; |
| 27 | use lsp_types::request::{ |
| 28 | CodeActionRequest, CodeLensRequest, ExecuteCommand, HoverRequest, Request as _, ShowDocument, |
| 29 | }; |
| 30 | use lsp_types::{ |
| 31 | CodeActionProviderCapability, CodeLensOptions, DidChangeTextDocumentParams, |
| 32 | DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, |
| 33 | ExecuteCommandOptions, HoverProviderCapability, PublishDiagnosticsParams, ServerCapabilities, |
| 34 | ShowDocumentParams, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions, |
| 35 | TextDocumentSyncSaveOptions, Url, |
| 36 | }; |
| 37 | |
| 38 | use crate::lens::{Lens, Outcome}; |
| 39 | use crate::render; |
| 40 | |
| 41 | /// The capabilities the lens advertises (`lens.serve`): full-text document |
| 42 | /// sync with save notifications (the compose flow needs the save, |
| 43 | /// `lens.compose`), code lenses (`lens.lenses`), hover (`lens.hover`), code |
| 44 | /// actions (`lens.compose`), and the four executable commands |
| 45 | /// (`lens.lenses`, `lens.compose`). No workspace, symbol, or completion |
| 46 | /// surface — the lens is a conversation view, not a language analyzer. |
| 47 | #[must_use] |
| 48 | pub fn capabilities() -> ServerCapabilities { |
| 49 | ServerCapabilities { |
| 50 | text_document_sync: Some(TextDocumentSyncCapability::Options( |
| 51 | TextDocumentSyncOptions { |
| 52 | open_close: Some(true), |
| 53 | change: Some(TextDocumentSyncKind::FULL), |
| 54 | save: Some(TextDocumentSyncSaveOptions::Supported(true)), |
| 55 | ..TextDocumentSyncOptions::default() |
| 56 | }, |
| 57 | )), |
| 58 | code_lens_provider: Some(CodeLensOptions { |
| 59 | resolve_provider: Some(false), |
| 60 | }), |
| 61 | hover_provider: Some(HoverProviderCapability::Simple(true)), |
| 62 | code_action_provider: Some(CodeActionProviderCapability::Simple(true)), |
| 63 | execute_command_provider: Some(ExecuteCommandOptions { |
| 64 | commands: vec![ |
| 65 | render::CMD_VIEW.to_owned(), |
| 66 | render::CMD_REPLY.to_owned(), |
| 67 | render::CMD_RESOLVE.to_owned(), |
| 68 | render::CMD_COMPOSE.to_owned(), |
| 69 | ], |
| 70 | work_done_progress_options: lsp_types::WorkDoneProgressOptions::default(), |
| 71 | }), |
| 72 | ..ServerCapabilities::default() |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /// Serve the lens over stdio until the client shuts it down (`lens.serve`). |
| 77 | /// |
| 78 | /// Performs the LSP initialize handshake advertising [`capabilities`], then |
| 79 | /// runs the dispatch loop. Binds no socket and speaks only stdin/stdout. |
| 80 | /// |
| 81 | /// # Errors |
| 82 | /// |
| 83 | /// [`std::io::Error`] if the JSON-RPC transport fails (a broken pipe, a |
| 84 | /// malformed frame) or the initialize handshake does not complete. |
| 85 | pub fn serve_stdio<O: Find + Write>(lens: Lens<O>) -> std::io::Result<()> { |
| 86 | let (connection, io_threads) = Connection::stdio(); |
| 87 | let capabilities = serde_json::to_value(capabilities()) |
| 88 | .map_err(|source| std::io::Error::other(source.to_string()))?; |
| 89 | let _init_params = connection |
| 90 | .initialize(capabilities) |
| 91 | .map_err(|source| std::io::Error::other(source.to_string()))?; |
| 92 | let mut server = ServerLoop { connection, lens }; |
| 93 | server.run()?; |
| 94 | // Drop the connection (and with it the writer's channel sender) before |
| 95 | // joining: the IO writer thread only terminates once its sender is gone, |
| 96 | // so joining while `server` still holds the connection would hang here |
| 97 | // forever. |
| 98 | drop(server); |
| 99 | io_threads.join()?; |
| 100 | Ok(()) |
| 101 | } |
| 102 | |
| 103 | /// The running dispatch loop: owns the connection and the lens, and a |
| 104 | /// counter for the ids of the server-initiated requests it sends (only |
| 105 | /// `window/showDocument`). |
| 106 | struct ServerLoop<O> { |
| 107 | connection: Connection, |
| 108 | lens: Lens<O>, |
| 109 | } |
| 110 | |
| 111 | impl<O: Find + Write> ServerLoop<O> { |
| 112 | fn run(&mut self) -> std::io::Result<()> { |
| 113 | // `iter()` yields until the client closes the connection; a |
| 114 | // `shutdown` request breaks the loop through `handle_shutdown`. |
| 115 | while let Ok(message) = self.connection.receiver.recv() { |
| 116 | match message { |
| 117 | Message::Request(request) => { |
| 118 | if self |
| 119 | .connection |
| 120 | .handle_shutdown(&request) |
| 121 | .map_err(|source| std::io::Error::other(source.to_string()))? |
| 122 | { |
| 123 | break; |
| 124 | } |
| 125 | self.on_request(request); |
| 126 | } |
| 127 | Message::Notification(notification) => self.on_notification(notification), |
| 128 | // Responses to our own `window/showDocument` requests carry |
| 129 | // nothing the lens needs to act on. |
| 130 | Message::Response(_) => {} |
| 131 | } |
| 132 | } |
| 133 | Ok(()) |
| 134 | } |
| 135 | |
| 136 | /// Route one request to a [`Lens`] read handler, replying with its |
| 137 | /// result or an error response. |
| 138 | fn on_request(&mut self, request: Request) { |
| 139 | let request = match self.request::<CodeLensRequest, _>(request, |lens, params| { |
| 140 | lens.code_lenses(¶ms.text_document.uri).map(Some) |
| 141 | }) { |
| 142 | Ok(()) => return, |
| 143 | Err(request) => request, |
| 144 | }; |
| 145 | let request = match self.request::<HoverRequest, _>(request, |lens, params| { |
| 146 | let position = params.text_document_position_params; |
| 147 | lens.hover(&position.text_document.uri, position.position) |
| 148 | }) { |
| 149 | Ok(()) => return, |
| 150 | Err(request) => request, |
| 151 | }; |
| 152 | let request = match self.request::<CodeActionRequest, _>(request, |lens, params| { |
| 153 | lens.code_actions(¶ms.text_document.uri, params.range) |
| 154 | .map(Some) |
| 155 | }) { |
| 156 | Ok(()) => return, |
| 157 | Err(request) => request, |
| 158 | }; |
| 159 | // `executeCommand` is the one request with side effects, handled on |
| 160 | // its own so its `Outcome` can drive `showDocument` and refreshes. |
| 161 | let request = match self.on_execute_command(request) { |
| 162 | Ok(()) => return, |
| 163 | Err(request) => request, |
| 164 | }; |
| 165 | // Any other request: an empty success, so a client probing an |
| 166 | // unsupported method gets a well-formed (null) reply, never a hang. |
| 167 | self.respond(Response::new_ok(request.id, serde_json::Value::Null)); |
| 168 | } |
| 169 | |
| 170 | /// Extract and answer a plain read request `R`, returning `Err(request)` |
| 171 | /// unchanged when it is a different method so the caller can try the |
| 172 | /// next. |
| 173 | fn request<R, F>(&mut self, request: Request, handle: F) -> Result<(), Request> |
| 174 | where |
| 175 | R: lsp_types::request::Request, |
| 176 | F: FnOnce(&Lens<O>, R::Params) -> crate::error::Result<R::Result>, |
| 177 | R::Result: serde::Serialize, |
| 178 | { |
| 179 | match request.extract::<R::Params>(R::METHOD) { |
| 180 | Ok((id, params)) => { |
| 181 | let response = match handle(&self.lens, params) { |
| 182 | Ok(result) => Response::new_ok(id, result), |
| 183 | Err(error) => error_response(id, &error), |
| 184 | }; |
| 185 | self.respond(response); |
| 186 | Ok(()) |
| 187 | } |
| 188 | Err(ExtractError::MethodMismatch(request)) => Err(request), |
| 189 | Err(ExtractError::JsonError { .. }) => { |
| 190 | // A malformed params payload for a method we do own: there is |
| 191 | // no id to reply against cleanly here, so drop it — the |
| 192 | // client will observe the missing response. |
| 193 | Ok(()) |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | /// Handle `workspace/executeCommand`, applying the [`Outcome`]: reply |
| 199 | /// with its result value, open the compose template if it named one, and |
| 200 | /// republish diagnostics when a mutation invalidated them. |
| 201 | fn on_execute_command(&mut self, request: Request) -> Result<(), Request> { |
| 202 | match request.extract::<<ExecuteCommand as lsp_types::request::Request>::Params>( |
| 203 | ExecuteCommand::METHOD, |
| 204 | ) { |
| 205 | Ok((id, params)) => { |
| 206 | match self |
| 207 | .lens |
| 208 | .execute_command(¶ms.command, ¶ms.arguments) |
| 209 | { |
| 210 | Ok(outcome) => { |
| 211 | let value = outcome.response.clone().unwrap_or(serde_json::Value::Null); |
| 212 | self.respond(Response::new_ok(id, value)); |
| 213 | self.apply(outcome); |
| 214 | } |
| 215 | Err(error) => self.respond(error_response(id, &error)), |
| 216 | } |
| 217 | Ok(()) |
| 218 | } |
| 219 | Err(ExtractError::MethodMismatch(request)) => Err(request), |
| 220 | Err(ExtractError::JsonError { .. }) => Ok(()), |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | /// Route one notification to the matching [`Lens`] document or save |
| 225 | /// handler, republishing diagnostics as the sync events demand. |
| 226 | fn on_notification(&mut self, notification: lsp_server::Notification) { |
| 227 | match notification.method.as_str() { |
| 228 | DidOpenTextDocument::METHOD => { |
| 229 | if let Ok(params) = extract_notification::<DidOpenTextDocumentParams>(notification) |
| 230 | { |
| 231 | let uri = params.text_document.uri.clone(); |
| 232 | self.lens.did_open(uri.clone(), params.text_document.text); |
| 233 | self.publish(&uri); |
| 234 | } |
| 235 | } |
| 236 | DidChangeTextDocument::METHOD => { |
| 237 | if let Ok(params) = |
| 238 | extract_notification::<DidChangeTextDocumentParams>(notification) |
| 239 | { |
| 240 | let uri = params.text_document.uri.clone(); |
| 241 | // Full sync: the last change carries the whole buffer. |
| 242 | if let Some(change) = params.content_changes.into_iter().next_back() { |
| 243 | self.lens.did_change(uri.clone(), change.text); |
| 244 | } |
| 245 | self.publish(&uri); |
| 246 | } |
| 247 | } |
| 248 | DidCloseTextDocument::METHOD => { |
| 249 | if let Ok(params) = extract_notification::<DidCloseTextDocumentParams>(notification) |
| 250 | { |
| 251 | self.lens.did_close(¶ms.text_document.uri); |
| 252 | // Clear this document's diagnostics on close. |
| 253 | self.publish_list(¶ms.text_document.uri, Vec::new()); |
| 254 | } |
| 255 | } |
| 256 | DidSaveTextDocument::METHOD => { |
| 257 | if let Ok(params) = extract_notification::<DidSaveTextDocumentParams>(notification) |
| 258 | { |
| 259 | match self.lens.did_save(¶ms.text_document.uri) { |
| 260 | Ok(outcome) => self.apply(outcome), |
| 261 | Err(error) => log(&format!("didSave: {error}")), |
| 262 | } |
| 263 | } |
| 264 | } |
| 265 | _ => {} |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | /// Apply an [`Outcome`]'s side effects: open the compose template and/or |
| 270 | /// republish diagnostics for every open document. |
| 271 | fn apply(&mut self, outcome: Outcome) { |
| 272 | if let Some(path) = outcome.show_document |
| 273 | && let Some(uri) = crate::document::file_uri(&path) |
| 274 | { |
| 275 | self.show_document(uri); |
| 276 | } |
| 277 | if outcome.refresh { |
| 278 | for uri in self.lens.open_documents() { |
| 279 | self.publish(&uri); |
| 280 | } |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | /// Compute and publish diagnostics for one document (`lens.diagnostics`). |
| 285 | fn publish(&self, uri: &Url) { |
| 286 | match self.lens.diagnostics_for(uri) { |
| 287 | Ok(diagnostics) => self.publish_list(uri, diagnostics), |
| 288 | Err(error) => log(&format!("diagnostics for {uri}: {error}")), |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | /// Send a `textDocument/publishDiagnostics` notification. |
| 293 | fn publish_list(&self, uri: &Url, diagnostics: Vec<lsp_types::Diagnostic>) { |
| 294 | let params = PublishDiagnosticsParams { |
| 295 | uri: uri.clone(), |
| 296 | diagnostics, |
| 297 | version: None, |
| 298 | }; |
| 299 | let notification = |
| 300 | lsp_server::Notification::new(PublishDiagnostics::METHOD.to_owned(), params); |
| 301 | let _ = self |
| 302 | .connection |
| 303 | .sender |
| 304 | .send(Message::Notification(notification)); |
| 305 | } |
| 306 | |
| 307 | /// Ask the client to open `uri` (`window/showDocument`) — the compose |
| 308 | /// template (`lens.compose`). |
| 309 | fn show_document(&self, uri: Url) { |
| 310 | let params = ShowDocumentParams { |
| 311 | uri, |
| 312 | external: Some(false), |
| 313 | take_focus: Some(true), |
| 314 | selection: None, |
| 315 | }; |
| 316 | let request = Request { |
| 317 | // A fixed id: the lens never correlates showDocument responses, |
| 318 | // and only one is ever in flight per user action. |
| 319 | id: RequestId::from("ents-show-document".to_owned()), |
| 320 | method: ShowDocument::METHOD.to_owned(), |
| 321 | params: serde_json::to_value(params).unwrap_or(serde_json::Value::Null), |
| 322 | }; |
| 323 | let _ = self.connection.sender.send(Message::Request(request)); |
| 324 | } |
| 325 | |
| 326 | fn respond(&self, response: Response) { |
| 327 | let _ = self.connection.sender.send(Message::Response(response)); |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | /// Build an LSP error response from a lens error, so a failing request gets |
| 332 | /// a well-formed fault rather than a dropped reply. |
| 333 | fn error_response(id: RequestId, error: &crate::error::Error) -> Response { |
| 334 | Response::new_err( |
| 335 | id, |
| 336 | lsp_server::ErrorCode::RequestFailed as i32, |
| 337 | error.to_string(), |
| 338 | ) |
| 339 | } |
| 340 | |
| 341 | /// Deserialize a notification's params, returning `Err` on a mismatch or a |
| 342 | /// malformed payload. |
| 343 | fn extract_notification<P: serde::de::DeserializeOwned>( |
| 344 | notification: lsp_server::Notification, |
| 345 | ) -> Result<P, ()> { |
| 346 | serde_json::from_value(notification.params).map_err(|_error| ()) |
| 347 | } |
| 348 | |
| 349 | /// Emit a diagnostic line on stderr — the lens's own log channel, since |
| 350 | /// stdout carries the LSP framing. |
| 351 | fn log(message: &str) { |
| 352 | eprintln!("ents-lens: {message}"); |
| 353 | } |