Skip to main content

adam_lsp/
dispatch.rs

1//! Wires `lsp-server`'s stdio transport to [`crate::diagnostics::diagnostics_for_source`].
2
3use std::collections::HashMap;
4
5use adam_lang::format_source;
6use lsp_server::{Connection, Message, Notification as ServerNotification, Request, Response};
7use lsp_types::{
8    DidChangeTextDocumentParams, DidOpenTextDocumentParams, DocumentFormattingParams, OneOf,
9    Position, PublishDiagnosticsParams, Range, ServerCapabilities, TextDocumentSyncCapability,
10    TextDocumentSyncKind, TextEdit, Uri,
11    notification::{
12        DidChangeTextDocument, DidOpenTextDocument, Notification as _, PublishDiagnostics,
13    },
14    request::{Formatting, Request as _},
15};
16
17use crate::diagnostics::diagnostics_for_source;
18
19/// The JSON-RPC "Method not found" error code, reused by LSP for unhandled request methods.
20const METHOD_NOT_FOUND: i32 = -32601;
21/// The JSON-RPC "Invalid params" error code, used when `textDocument/formatting`'s params fail
22/// to deserialize.
23const INVALID_PARAMS: i32 = -32602;
24
25/// Runs the adam-lang language server on stdin/stdout until the client sends `exit`.
26///
27/// # Errors
28///
29/// Returns `Err` if the initialize handshake fails, a message can't be read from or written to
30/// stdio, or the background reader/writer threads panic.
31pub fn run() -> anyhow::Result<()> {
32    let (connection, io_threads) = Connection::stdio();
33    serve(&connection)?;
34    io_threads.join()?;
35    Ok(())
36}
37
38/// Performs the LSP initialize handshake on `connection`, then serves `textDocument/didOpen`
39/// and `textDocument/didChange` notifications as `textDocument/publishDiagnostics` until the
40/// client shuts the server down.
41///
42/// Exposed separately from [`run`] so tests can drive an in-memory [`Connection::memory`] pair
43/// instead of real stdio.
44///
45/// # Errors
46///
47/// Returns `Err` under the same conditions as [`run`].
48pub fn serve(connection: &Connection) -> anyhow::Result<()> {
49    let capabilities = serde_json::to_value(ServerCapabilities {
50        text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
51        document_formatting_provider: Some(OneOf::Left(true)),
52        ..Default::default()
53    })?;
54    connection.initialize(capabilities)?;
55    main_loop(connection)
56}
57
58/// Dispatches every message on `connection` until a `shutdown`/`exit` sequence ends the server.
59///
60/// # Errors
61///
62/// Returns `Err` if a message can't be read from or sent to `connection` (a broken transport).
63// `lsp_types::Uri`'s `Hash`/`Eq` are keyed on `as_str()` only (see its `impl Hash for Uri`); the
64// interior-mutable field clippy's flagging here never participates in either, so it's safe as a
65// map key despite the lint.
66#[allow(clippy::mutable_key_type)]
67fn main_loop(connection: &Connection) -> anyhow::Result<()> {
68    let mut documents: HashMap<Uri, String> = HashMap::new();
69    for msg in &connection.receiver {
70        match msg {
71            Message::Request(req) => {
72                if connection.handle_shutdown(&req)? {
73                    return Ok(());
74                }
75                handle_request(connection, &documents, req)?;
76            }
77            Message::Notification(not) => handle_notification(connection, &mut documents, not)?,
78            Message::Response(_) => {}
79        }
80    }
81    Ok(())
82}
83
84/// Handles one client request. Only `textDocument/formatting` is implemented; every other method
85/// gets a JSON-RPC "Method not found" response (`shutdown` is intercepted earlier, in
86/// `main_loop`, and never reaches here).
87///
88/// # Errors
89///
90/// Returns `Err` only if sending the response fails (a broken transport).
91// See the matching `#[allow]` on `main_loop` for why `HashMap<Uri, _>` is fine despite the lint.
92#[allow(clippy::mutable_key_type)]
93fn handle_request(
94    connection: &Connection,
95    documents: &HashMap<Uri, String>,
96    req: Request,
97) -> anyhow::Result<()> {
98    match req.method.as_str() {
99        Formatting::METHOD => {
100            let id = req.id.clone();
101            match req.extract::<DocumentFormattingParams>(Formatting::METHOD) {
102                Ok((id, params)) => {
103                    // A URI not present in `documents` (never seen via didOpen/didChange) silently
104                    // gets no edits, the same "nothing to do" response as a syntax error — not an error
105                    // response.
106                    let edits = documents
107                        .get(&params.text_document.uri)
108                        .map(|source| format_edits(source))
109                        .unwrap_or_default();
110                    connection
111                        .sender
112                        .send(Message::Response(Response::new_ok(id, edits)))?;
113                }
114                Err(error) => {
115                    connection.sender.send(Message::Response(Response::new_err(
116                        id,
117                        INVALID_PARAMS,
118                        error.to_string(),
119                    )))?;
120                }
121            }
122        }
123        _ => {
124            let response = Response::new_err(
125                req.id.clone(),
126                METHOD_NOT_FOUND,
127                format!("unhandled method: {}", req.method),
128            );
129            connection.sender.send(Message::Response(response))?;
130        }
131    }
132    Ok(())
133}
134
135/// Handles one client notification, publishing fresh diagnostics on `didOpen`/`didChange` and
136/// recording the document's current text in `documents` so a later `textDocument/formatting`
137/// request can look it up by URI (that request's params carry only a URI, not the text).
138///
139/// # Errors
140///
141/// Returns `Err` only if sending the resulting `publishDiagnostics` notification fails (a broken
142/// transport). A `didOpen`/`didChange` notification whose params fail to deserialize is logged to
143/// stderr and skipped rather than propagated, so one malformed client message can't take down the
144/// server.
145// See the matching `#[allow]` on `main_loop` for why `HashMap<Uri, _>` is fine despite the lint.
146#[allow(clippy::mutable_key_type)]
147fn handle_notification(
148    connection: &Connection,
149    documents: &mut HashMap<Uri, String>,
150    not: ServerNotification,
151) -> anyhow::Result<()> {
152    match not.method.as_str() {
153        DidOpenTextDocument::METHOD => {
154            let params: DidOpenTextDocumentParams = match not.extract(DidOpenTextDocument::METHOD) {
155                Ok(params) => params,
156                Err(error) => {
157                    eprintln!(
158                        "adam-lsp: ignoring malformed {}: {error}",
159                        DidOpenTextDocument::METHOD
160                    );
161                    return Ok(());
162                }
163            };
164            documents.insert(
165                params.text_document.uri.clone(),
166                params.text_document.text.clone(),
167            );
168            publish(
169                connection,
170                &params.text_document.uri,
171                &params.text_document.text,
172            )?;
173        }
174        DidChangeTextDocument::METHOD => {
175            let params: DidChangeTextDocumentParams =
176                match not.extract(DidChangeTextDocument::METHOD) {
177                    Ok(params) => params,
178                    Err(error) => {
179                        eprintln!(
180                            "adam-lsp: ignoring malformed {}: {error}",
181                            DidChangeTextDocument::METHOD
182                        );
183                        return Ok(());
184                    }
185                };
186            if let Some(change) = params.content_changes.into_iter().last() {
187                documents.insert(params.text_document.uri.clone(), change.text.clone());
188                publish(connection, &params.text_document.uri, &change.text)?;
189            }
190        }
191        _ => {}
192    }
193    Ok(())
194}
195
196/// Computes diagnostics for `source` and sends them as a `textDocument/publishDiagnostics`
197/// notification for `uri`.
198///
199/// # Errors
200///
201/// Returns `Err` if sending the notification on `connection` fails (a broken transport).
202fn publish(connection: &Connection, uri: &Uri, source: &str) -> anyhow::Result<()> {
203    let params = PublishDiagnosticsParams {
204        uri: uri.clone(),
205        diagnostics: diagnostics_for_source(source),
206        version: None,
207    };
208    let notification = ServerNotification::new(PublishDiagnostics::METHOD.to_string(), params);
209    connection
210        .sender
211        .send(Message::Notification(notification))?;
212    Ok(())
213}
214
215/// Computes the `textDocument/formatting` edit for adam-lang `source`.
216///
217/// - Postcondition: returns an empty `Vec` if [`format_source`] refuses `source` (it doesn't
218///   parse, or parses with any recovered syntax error) — refusing to format code it can't fully
219///   understand, matching `rustfmt`. Otherwise returns exactly one [`TextEdit`] replacing the
220///   whole document with `format_source`'s output.
221/// - Complexity: O(n) in the length of `source` — parses, attaches trivia to, and formats the
222///   whole sheet once, with no caching across calls.
223fn format_edits(source: &str) -> Vec<TextEdit> {
224    match format_source(source) {
225        Ok(formatted) => vec![TextEdit {
226            range: whole_document_range(),
227            new_text: formatted,
228        }],
229        Err(_) => Vec::new(),
230    }
231}
232
233/// A `Range` guaranteed to cover an entire document regardless of its actual length — LSP
234/// clients clamp an out-of-bounds end position to the document's real end, so this avoids
235/// needing to compute the exact last line/column of `source` (and getting it wrong for the
236/// common trailing-newline edge case).
237fn whole_document_range() -> Range {
238    Range {
239        start: Position {
240            line: 0,
241            character: 0,
242        },
243        end: Position {
244            line: u32::MAX,
245            character: u32::MAX,
246        },
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use lsp_server::{Connection, Message, Notification as ServerNotification, Request, RequestId};
253    use lsp_types::{
254        DidChangeTextDocumentParams, DidOpenTextDocumentParams, DocumentFormattingParams,
255        PublishDiagnosticsParams, TextDocumentContentChangeEvent, TextDocumentIdentifier,
256        TextDocumentItem, TextEdit, VersionedTextDocumentIdentifier,
257        notification::{
258            DidChangeTextDocument, DidOpenTextDocument, Notification as _, PublishDiagnostics,
259        },
260        request::{Formatting, Request as _},
261    };
262
263    use super::{format_edits, serve};
264
265    #[test]
266    fn format_edits_is_empty_for_a_syntax_error() {
267        assert!(format_edits("not a sheet at all").is_empty());
268    }
269
270    #[test]
271    fn format_edits_is_empty_for_a_recovered_syntax_error() {
272        assert!(format_edits("sheet s { cell x unknown_syntax }").is_empty());
273    }
274
275    #[test]
276    fn format_edits_returns_one_edit_replacing_the_whole_document() {
277        let edits = format_edits("sheet   s{cell x:i32=1;}");
278        assert_eq!(edits.len(), 1);
279        assert_eq!(edits[0].new_text, "sheet s {\n    cell x: i32 = 1;\n}\n");
280    }
281
282    #[test]
283    fn format_edits_formats_a_cell_with_a_filter() {
284        let edits = format_edits("sheet s { cell a:i32=1 filter _; }");
285        assert_eq!(edits.len(), 1);
286        assert_eq!(
287            edits[0].new_text,
288            "sheet s {\n    cell a: i32 = 1 filter _;\n}\n"
289        );
290    }
291
292    #[test]
293    fn format_edits_formats_a_cell_with_a_range_inclusive_filter() {
294        // Regression test: formatting a range-literal filter (as in
295        // begin/examples/inequality.adm2's `filter 0..=200`) used to panic inside
296        // cel_parser::fmt::binary_op_level ("unknown operator `range_inclusive`"), crashing
297        // adam-lsp's single-threaded server mid-request — which VS Code's format-on-save then
298        // waited on forever, indistinguishable from a hang.
299        let edits = format_edits("sheet s { cell a:i32=0 filter 0..=200; }");
300        assert_eq!(edits.len(), 1);
301        assert_eq!(
302            edits[0].new_text,
303            "sheet s {\n    cell a: i32 = 0 filter 0..=200;\n}\n"
304        );
305    }
306
307    #[test]
308    fn formatting_request_returns_the_edit_for_a_previously_opened_document() {
309        let (server, client) = Connection::memory();
310        let server_thread = std::thread::spawn(move || serve(&server));
311        initialize(&client);
312
313        let uri: lsp_types::Uri = "file:///test.adm2".parse().unwrap();
314        client
315            .sender
316            .send(Message::Notification(ServerNotification::new(
317                DidOpenTextDocument::METHOD.to_string(),
318                DidOpenTextDocumentParams {
319                    text_document: TextDocumentItem {
320                        uri: uri.clone(),
321                        language_id: "adam-lang".to_string(),
322                        version: 1,
323                        text: "sheet s { cell x: i32 = 1; }".to_string(),
324                    },
325                },
326            )))
327            .unwrap();
328        expect_published(&client); // the didOpen's diagnostics notification
329
330        client
331            .sender
332            .send(Message::Request(Request::new(
333                RequestId::from(3),
334                Formatting::METHOD.to_string(),
335                DocumentFormattingParams {
336                    text_document: TextDocumentIdentifier { uri: uri.clone() },
337                    options: lsp_types::FormattingOptions {
338                        tab_size: 4,
339                        insert_spaces: true,
340                        ..Default::default()
341                    },
342                    work_done_progress_params: Default::default(),
343                },
344            )))
345            .unwrap();
346        let response = match client.receiver.recv().unwrap() {
347            Message::Response(r) => r,
348            other => panic!("expected a response, got {other:?}"),
349        };
350        let edits: Vec<TextEdit> =
351            serde_json::from_value(response.response_result.unwrap()).unwrap();
352        assert_eq!(edits.len(), 1);
353        assert_eq!(edits[0].new_text, "sheet s {\n    cell x: i32 = 1;\n}\n");
354
355        shut_down(&client);
356        server_thread.join().unwrap().unwrap();
357    }
358
359    /// Sends the `initialize` -> `initialized` handshake on `client`, discarding the response.
360    fn initialize(client: &Connection) {
361        client
362            .sender
363            .send(Message::Request(Request::new(
364                RequestId::from(1),
365                "initialize".to_string(),
366                serde_json::json!({}),
367            )))
368            .unwrap();
369        client.receiver.recv().unwrap();
370        client
371            .sender
372            .send(Message::Notification(ServerNotification::new(
373                "initialized".to_string(),
374                serde_json::json!({}),
375            )))
376            .unwrap();
377    }
378
379    /// Sends the `shutdown` -> `exit` sequence on `client`, discarding the response.
380    fn shut_down(client: &Connection) {
381        client
382            .sender
383            .send(Message::Request(Request::new(
384                RequestId::from(2),
385                "shutdown".to_string(),
386                serde_json::json!(null),
387            )))
388            .unwrap();
389        client.receiver.recv().unwrap();
390        client
391            .sender
392            .send(Message::Notification(ServerNotification::new(
393                "exit".to_string(),
394                serde_json::json!(null),
395            )))
396            .unwrap();
397    }
398
399    /// Receives the next message from `client` and asserts it's a `publishDiagnostics`
400    /// notification, returning its deserialized params.
401    fn expect_published(client: &Connection) -> PublishDiagnosticsParams {
402        let published = match client.receiver.recv().unwrap() {
403            Message::Notification(n) => n,
404            other => panic!("expected a notification, got {other:?}"),
405        };
406        assert_eq!(published.method, PublishDiagnostics::METHOD);
407        serde_json::from_value(published.params).unwrap()
408    }
409
410    #[test]
411    fn open_notification_triggers_a_publish_diagnostics_notification() {
412        let (server, client) = Connection::memory();
413        let server_thread = std::thread::spawn(move || serve(&server));
414        initialize(&client);
415
416        let uri: lsp_types::Uri = "file:///test.pm".parse().unwrap();
417        client
418            .sender
419            .send(Message::Notification(ServerNotification::new(
420                DidOpenTextDocument::METHOD.to_string(),
421                DidOpenTextDocumentParams {
422                    text_document: TextDocumentItem {
423                        uri: uri.clone(),
424                        language_id: "adam-lang".to_string(),
425                        version: 1,
426                        text: "sheet s { cell x: i32 = 1.0; }".to_string(),
427                    },
428                },
429            )))
430            .unwrap();
431
432        let params = expect_published(&client);
433        assert_eq!(params.uri, uri);
434        assert_eq!(params.diagnostics.len(), 1);
435
436        shut_down(&client);
437        server_thread.join().unwrap().unwrap();
438    }
439
440    #[test]
441    fn malformed_open_notification_is_skipped_not_crashed() {
442        let (server, client) = Connection::memory();
443        let server_thread = std::thread::spawn(move || serve(&server));
444        initialize(&client);
445
446        // Params that don't deserialize as `DidOpenTextDocumentParams` at all.
447        client
448            .sender
449            .send(Message::Notification(ServerNotification::new(
450                DidOpenTextDocument::METHOD.to_string(),
451                serde_json::json!({"not": "valid"}),
452            )))
453            .unwrap();
454
455        // A subsequent well-formed `didOpen` still gets a normal `publishDiagnostics` response,
456        // proving the server is still alive and serving requests after the malformed message.
457        let uri: lsp_types::Uri = "file:///test.pm".parse().unwrap();
458        client
459            .sender
460            .send(Message::Notification(ServerNotification::new(
461                DidOpenTextDocument::METHOD.to_string(),
462                DidOpenTextDocumentParams {
463                    text_document: TextDocumentItem {
464                        uri: uri.clone(),
465                        language_id: "adam-lang".to_string(),
466                        version: 1,
467                        text: "sheet s { cell x: i32 = 1; }".to_string(),
468                    },
469                },
470            )))
471            .unwrap();
472
473        let params = expect_published(&client);
474        assert_eq!(params.uri, uri);
475        assert!(params.diagnostics.is_empty());
476
477        shut_down(&client);
478        server_thread.join().unwrap().unwrap();
479    }
480
481    #[test]
482    fn change_notification_after_open_triggers_a_second_publish_diagnostics_notification() {
483        let (server, client) = Connection::memory();
484        let server_thread = std::thread::spawn(move || serve(&server));
485        initialize(&client);
486
487        let uri: lsp_types::Uri = "file:///test.pm".parse().unwrap();
488        client
489            .sender
490            .send(Message::Notification(ServerNotification::new(
491                DidOpenTextDocument::METHOD.to_string(),
492                DidOpenTextDocumentParams {
493                    text_document: TextDocumentItem {
494                        uri: uri.clone(),
495                        language_id: "adam-lang".to_string(),
496                        version: 1,
497                        text: "sheet s { cell x: i32 = 1; }".to_string(),
498                    },
499                },
500            )))
501            .unwrap();
502        let first = expect_published(&client);
503        assert!(first.diagnostics.is_empty());
504
505        client
506            .sender
507            .send(Message::Notification(ServerNotification::new(
508                DidChangeTextDocument::METHOD.to_string(),
509                DidChangeTextDocumentParams {
510                    text_document: VersionedTextDocumentIdentifier {
511                        uri: uri.clone(),
512                        version: 2,
513                    },
514                    content_changes: vec![TextDocumentContentChangeEvent {
515                        range: None,
516                        range_length: None,
517                        text: "sheet s { cell x: i32 = 1.0; }".to_string(),
518                    }],
519                },
520            )))
521            .unwrap();
522        let second = expect_published(&client);
523        assert_eq!(second.uri, uri);
524        assert_eq!(second.diagnostics.len(), 1);
525
526        shut_down(&client);
527        server_thread.join().unwrap().unwrap();
528    }
529
530    #[test]
531    fn unrecognized_request_method_gets_a_method_not_found_response() {
532        let (server, client) = Connection::memory();
533        let server_thread = std::thread::spawn(move || serve(&server));
534        initialize(&client);
535
536        client
537            .sender
538            .send(Message::Request(Request::new(
539                RequestId::from(3),
540                "textDocument/hover".to_string(),
541                serde_json::json!({}),
542            )))
543            .unwrap();
544
545        let response = match client.receiver.recv().unwrap() {
546            Message::Response(r) => r,
547            other => panic!("expected a response, got {other:?}"),
548        };
549        assert!(response.response_result.is_err());
550
551        shut_down(&client);
552        server_thread.join().unwrap().unwrap();
553    }
554}