Skip to main content

bock_lsp/
server.rs

1//! [`BockLanguageServer`] — stdio LSP server implementing the
2//! [`tower_lsp::LanguageServer`] trait.
3//!
4//! F.1.2 adds live diagnostics: `did_open`/`did_change` run the full check
5//! pipeline on the edited document and publish the resulting diagnostics
6//! back to the client. `did_close` clears them.
7
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use dashmap::DashMap;
12use tower_lsp::jsonrpc::Result as LspResult;
13use tower_lsp::lsp_types::{
14    DiagnosticOptions, DiagnosticServerCapabilities, DidChangeTextDocumentParams,
15    DidCloseTextDocumentParams, DidOpenTextDocumentParams, GotoDefinitionParams,
16    GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability,
17    InitializeParams, InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind,
18    MessageType, OneOf, ServerCapabilities, ServerInfo, TextDocumentSyncCapability,
19    TextDocumentSyncKind, Url, WorkDoneProgressOptions,
20};
21use tower_lsp::{Client, LanguageServer};
22
23use crate::diagnostics::{span_to_range, to_lsp_diagnostic};
24use crate::goto_definition::find_definition;
25use crate::hover::hover;
26use crate::pipeline::check_document;
27
28/// The Bock language server.
29///
30/// Holds a [`Client`] handle for publishing diagnostics and logging, plus a
31/// concurrent map of open documents keyed by their URI.
32#[derive(Debug)]
33pub struct BockLanguageServer {
34    client: Client,
35    documents: Arc<DashMap<Url, String>>,
36}
37
38impl BockLanguageServer {
39    /// Construct a new server bound to the given LSP client.
40    #[must_use]
41    pub fn new(client: Client) -> Self {
42        Self {
43            client,
44            documents: Arc::new(DashMap::new()),
45        }
46    }
47
48    fn server_capabilities() -> ServerCapabilities {
49        ServerCapabilities {
50            text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
51            hover_provider: Some(HoverProviderCapability::Simple(true)),
52            definition_provider: Some(OneOf::Left(true)),
53            diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
54                identifier: Some("bock".to_string()),
55                inter_file_dependencies: true,
56                workspace_diagnostics: false,
57                work_done_progress_options: WorkDoneProgressOptions::default(),
58            })),
59            ..ServerCapabilities::default()
60        }
61    }
62
63    /// Re-run the check pipeline on `uri`'s current contents and publish the
64    /// resulting diagnostics. No-op if the URI is not in the document store.
65    async fn publish(&self, uri: Url, version: Option<i32>) {
66        let Some(content) = self.documents.get(&uri).map(|e| e.value().clone()) else {
67            return;
68        };
69
70        let path = url_to_path(&uri);
71        let uri_for_task = uri.clone();
72        // The pipeline is CPU-bound — hop to a blocking thread so we don't
73        // stall the LSP reactor when checking a large file.
74        let result =
75            tokio::task::spawn_blocking(move || check_document(path, content)).await;
76
77        let result = match result {
78            Ok(r) => r,
79            Err(err) => {
80                self.client
81                    .log_message(MessageType::ERROR, format!("check pipeline panicked: {err}"))
82                    .await;
83                return;
84            }
85        };
86
87        let source_file = result.source_map.get_file(result.file_id);
88        let lsp_diags: Vec<_> = result
89            .diagnostics
90            .iter()
91            .map(|d| to_lsp_diagnostic(d, &uri_for_task, source_file))
92            .collect();
93
94        self.client
95            .publish_diagnostics(uri_for_task, lsp_diags, version)
96            .await;
97    }
98}
99
100/// Best-effort conversion of a `file://` URI to a [`PathBuf`]. Non-file URIs
101/// fall back to the raw path component so diagnostics still render — a
102/// synthetic filename is harmless because the LSP never reads from disk.
103fn url_to_path(uri: &Url) -> PathBuf {
104    uri.to_file_path()
105        .unwrap_or_else(|_| PathBuf::from(uri.path()))
106}
107
108#[tower_lsp::async_trait]
109impl LanguageServer for BockLanguageServer {
110    async fn initialize(&self, _params: InitializeParams) -> LspResult<InitializeResult> {
111        Ok(InitializeResult {
112            capabilities: Self::server_capabilities(),
113            server_info: Some(ServerInfo {
114                name: "bock-lsp".to_string(),
115                version: Some(env!("CARGO_PKG_VERSION").to_string()),
116            }),
117        })
118    }
119
120    async fn initialized(&self, _: InitializedParams) {
121        self.client
122            .log_message(MessageType::INFO, "Bock LSP ready")
123            .await;
124    }
125
126    async fn shutdown(&self) -> LspResult<()> {
127        Ok(())
128    }
129
130    async fn did_open(&self, params: DidOpenTextDocumentParams) {
131        let doc = params.text_document;
132        self.documents.insert(doc.uri.clone(), doc.text);
133        self.publish(doc.uri, Some(doc.version)).await;
134    }
135
136    async fn did_change(&self, params: DidChangeTextDocumentParams) {
137        let uri = params.text_document.uri;
138        // We advertise `TextDocumentSyncKind::FULL`, so each change event
139        // carries the complete new document text in `text`. Apply the last
140        // event (the one the client considers authoritative).
141        if let Some(change) = params.content_changes.into_iter().last() {
142            self.documents.insert(uri.clone(), change.text);
143        }
144        self.publish(uri, Some(params.text_document.version)).await;
145    }
146
147    async fn goto_definition(
148        &self,
149        params: GotoDefinitionParams,
150    ) -> LspResult<Option<GotoDefinitionResponse>> {
151        let uri = params.text_document_position_params.text_document.uri;
152        let pos = params.text_document_position_params.position;
153
154        let Some(content) = self.documents.get(&uri).map(|e| e.value().clone()) else {
155            return Ok(None);
156        };
157
158        let path = url_to_path(&uri);
159        // Pipeline is CPU-bound — hop to a blocking thread so we don't
160        // stall the LSP reactor.
161        let result = tokio::task::spawn_blocking(move || {
162            find_definition(path, content, pos.line, pos.character)
163        })
164        .await;
165
166        let result = match result {
167            Ok(r) => r,
168            Err(err) => {
169                self.client
170                    .log_message(
171                        MessageType::ERROR,
172                        format!("goto_definition panicked: {err}"),
173                    )
174                    .await;
175                return Ok(None);
176            }
177        };
178
179        let Some(def) = result else { return Ok(None) };
180
181        let source_file = def.source_map.get_file(def.file_id);
182        let range = span_to_range(def.target, source_file);
183        Ok(Some(GotoDefinitionResponse::Scalar(Location {
184            uri,
185            range,
186        })))
187    }
188
189    async fn hover(&self, params: HoverParams) -> LspResult<Option<Hover>> {
190        let uri = params.text_document_position_params.text_document.uri;
191        let pos = params.text_document_position_params.position;
192
193        let Some(content) = self.documents.get(&uri).map(|e| e.value().clone()) else {
194            return Ok(None);
195        };
196
197        let path = url_to_path(&uri);
198        // Pipeline is CPU-bound — hop to a blocking thread so we don't
199        // stall the LSP reactor.
200        let result =
201            tokio::task::spawn_blocking(move || hover(path, content, pos.line, pos.character))
202                .await;
203
204        let result = match result {
205            Ok(r) => r,
206            Err(err) => {
207                self.client
208                    .log_message(MessageType::ERROR, format!("hover panicked: {err}"))
209                    .await;
210                return Ok(None);
211            }
212        };
213
214        let Some(info) = result else { return Ok(None) };
215
216        let source_file = info.source_map.get_file(info.file_id);
217        let range = span_to_range(info.span, source_file);
218        Ok(Some(Hover {
219            contents: HoverContents::Markup(MarkupContent {
220                kind: MarkupKind::Markdown,
221                value: info.contents,
222            }),
223            range: Some(range),
224        }))
225    }
226
227    async fn did_close(&self, params: DidCloseTextDocumentParams) {
228        let uri = params.text_document.uri;
229        self.documents.remove(&uri);
230        // Clear any diagnostics we previously published for this file.
231        self.client.publish_diagnostics(uri, Vec::new(), None).await;
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn capabilities_declare_required_providers() {
241        let caps = BockLanguageServer::server_capabilities();
242
243        match caps.text_document_sync {
244            Some(TextDocumentSyncCapability::Kind(kind)) => {
245                assert_eq!(kind, TextDocumentSyncKind::FULL);
246            }
247            _ => panic!("expected Full text document sync"),
248        }
249
250        assert!(
251            matches!(caps.hover_provider, Some(HoverProviderCapability::Simple(true))),
252            "hover provider must be enabled",
253        );
254
255        assert!(
256            matches!(caps.definition_provider, Some(OneOf::Left(true))),
257            "definition provider must be enabled",
258        );
259
260        assert!(
261            caps.diagnostic_provider.is_some(),
262            "diagnostic provider must be declared for F.1.2",
263        );
264    }
265
266    #[test]
267    fn url_to_path_handles_file_uri() {
268        let url = Url::parse("file:///tmp/foo.bock").unwrap();
269        assert_eq!(url_to_path(&url), PathBuf::from("/tmp/foo.bock"));
270    }
271
272    #[test]
273    fn url_to_path_falls_back_for_non_file_scheme() {
274        let url = Url::parse("untitled:Untitled-1").unwrap();
275        let path = url_to_path(&url);
276        assert!(!path.as_os_str().is_empty());
277    }
278}