Skip to main content

ontocore_lsp/
lib.rs

1//! Language server for OntoCode (stdio). Custom methods under `ontocore/*`.
2//!
3//! See [docs/lsp-api.md](https://github.com/eddiethedean/ontocode/blob/main/docs/lsp-api.md).
4//!
5//! # API stability
6//!
7//! The LSP wire format and custom `ontocore/*` methods are **pre-1.0** and may change
8//! between minor releases. See the repository README for semver policy.
9
10pub(crate) mod code_actions;
11pub(crate) mod completion;
12pub(crate) mod diagnostics;
13pub(crate) mod handlers;
14pub(crate) mod index_worker;
15pub(crate) mod positions;
16pub mod protocol;
17pub(crate) mod semantic_tokens;
18pub(crate) mod state;
19
20use handlers::build_catalog_snapshot;
21use ontocore_catalog::OntologyCatalog;
22
23/// Serialize the LSP `ontocore/getCatalogSnapshot` payload for a built catalog.
24pub fn catalog_snapshot_json(
25    catalog: &OntologyCatalog,
26) -> Result<serde_json::Value, serde_json::Error> {
27    serde_json::to_value(build_catalog_snapshot(catalog))
28}
29
30#[cfg(test)]
31mod handlers_test;
32
33use crate::positions::utf16_offset_to_byte;
34use crossbeam_channel::Sender;
35use diagnostics::publish_diagnostics_for_state;
36use handlers::{
37    handle_custom_request, handle_initialize, handle_standard_request, StandardRequestOutcome,
38};
39use index_worker::IndexWorker;
40use lsp_server::{Connection, Message, Notification, Request, RequestId, Response, ResponseError};
41use lsp_types::{
42    notification::Notification as _,
43    notification::{
44        DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, Exit, Initialized,
45    },
46    InitializeParams,
47};
48use serde_json::Value;
49use state::{canonical_roots_match, resolve_workspace_folder_uri, ServerState};
50use std::sync::atomic::{AtomicBool, Ordering};
51use std::sync::{Arc, Mutex};
52use std::thread;
53use std::time::{Duration, Instant};
54
55struct PendingReindex {
56    workspace: std::path::PathBuf,
57    scheduled_at: Instant,
58}
59
60pub fn run() -> Result<(), Box<dyn std::error::Error>> {
61    let (connection, io_threads) = Connection::stdio();
62    let state = ServerState::new();
63    let pending_reindex: Arc<Mutex<Option<PendingReindex>>> = Arc::new(Mutex::new(None));
64    let pending_diagnostic_publish = Arc::new(AtomicBool::new(false));
65
66    let index_worker = IndexWorker::spawn(state.clone(), connection.sender.clone());
67
68    let timer_worker = index_worker.clone();
69    let timer_pending = Arc::clone(&pending_reindex);
70    thread::spawn(move || loop {
71        thread::sleep(Duration::from_millis(100));
72        if let Some(workspace) = take_due_reindex_workspace(&timer_pending) {
73            timer_worker.enqueue(workspace);
74        }
75    });
76
77    for msg in &connection.receiver {
78        match msg {
79            Message::Request(req) => {
80                if connection.handle_shutdown(&req)? {
81                    break;
82                }
83                if let Some(resp) =
84                    handle_lsp_request(&state, &index_worker, &pending_diagnostic_publish, req)
85                {
86                    connection.sender.send(Message::Response(resp))?;
87                }
88                publish_pending_diagnostics(
89                    &connection.sender,
90                    &state,
91                    &pending_diagnostic_publish,
92                );
93            }
94            Message::Notification(notif) => {
95                if notif.method == Exit::METHOD {
96                    break;
97                }
98                handle_notification(&state, &pending_reindex, notif);
99                publish_pending_diagnostics(
100                    &connection.sender,
101                    &state,
102                    &pending_diagnostic_publish,
103                );
104            }
105            Message::Response(_) => {}
106        }
107    }
108
109    drop(connection);
110    io_threads.join()?;
111    Ok(())
112}
113
114fn handle_lsp_request(
115    state: &ServerState,
116    index_worker: &IndexWorker,
117    pending_diagnostic_publish: &Arc<AtomicBool>,
118    req: Request,
119) -> Option<Response> {
120    let id = req.id.clone();
121
122    if req.method == "initialize" {
123        let params: InitializeParams = match parse_params(Some(req.params)) {
124            Ok(p) => p,
125            Err(e) => return Some(error_response(id, e)),
126        };
127        let result = handle_initialize(state, params);
128        if state.with_catalog(|_| ()).is_some() {
129            pending_diagnostic_publish.store(true, Ordering::SeqCst);
130        }
131        return Some(ok_response(id, result));
132    }
133
134    if req.method == "shutdown" {
135        return Some(ok_response(id, Value::Null));
136    }
137
138    if req.method.starts_with("ontocore/") {
139        return match handle_custom_request(state, index_worker, &req.method, Some(req.params)) {
140            Ok(result) => {
141                if req.method == "ontocore/indexWorkspace" {
142                    pending_diagnostic_publish.store(true, Ordering::SeqCst);
143                }
144                Some(ok_response(id, result))
145            }
146            Err(err) => Some(ontocore_error_response(id, err)),
147        };
148    }
149
150    match handle_standard_request(state, &req.method, Some(req.params)) {
151        StandardRequestOutcome::Ok(result) => Some(ok_response(id, result)),
152        StandardRequestOutcome::MethodNotFound => Some(error_response(
153            id,
154            ResponseError {
155                code: -32601,
156                message: format!("Method not found: {}", req.method),
157                data: None,
158            },
159        )),
160        StandardRequestOutcome::InvalidParams(err) => Some(error_response(id, err)),
161        StandardRequestOutcome::LspError(err) => Some(ontocore_error_response(id, err)),
162    }
163}
164
165fn handle_notification(
166    state: &ServerState,
167    pending_reindex: &Arc<Mutex<Option<PendingReindex>>>,
168    notif: Notification,
169) {
170    match notif.method.as_str() {
171        Initialized::METHOD => {
172            if let Some(workspace) = state.effective_index_root() {
173                schedule_reindex(pending_reindex, workspace, Duration::from_millis(500));
174            }
175        }
176        "workspace/didChangeWatchedFiles" => {
177            if let Some(workspace) = state.effective_index_root() {
178                schedule_reindex(pending_reindex, workspace, Duration::from_millis(500));
179            }
180        }
181        DidOpenTextDocument::METHOD => {
182            if let Ok(params) =
183                serde_json::from_value::<lsp_types::DidOpenTextDocumentParams>(notif.params)
184            {
185                if let Ok(path) = document_path_from_uri(state, &params.text_document.uri) {
186                    if let Err(err) =
187                        state.set_document_text(path.clone(), params.text_document.text)
188                    {
189                        eprintln!("ontocore-lsp: rejected open document: {err}");
190                    } else {
191                        state.set_document_version(path, params.text_document.version);
192                    }
193                }
194            }
195            if let Some(workspace) = state.effective_index_root() {
196                schedule_reindex(pending_reindex, workspace, Duration::from_millis(750));
197            }
198        }
199        DidChangeTextDocument::METHOD => {
200            if let Ok(params) =
201                serde_json::from_value::<lsp_types::DidChangeTextDocumentParams>(notif.params)
202            {
203                if let Ok(path) = document_path_from_uri(state, &params.text_document.uri) {
204                    if let Some(text) = merged_document_text(state, &path, &params) {
205                        if let Err(err) = state.set_document_text(path.clone(), text) {
206                            eprintln!("ontocore-lsp: rejected document change: {err}");
207                        } else {
208                            state.set_document_version(path, params.text_document.version);
209                            if let Some(workspace) = state.effective_index_root() {
210                                schedule_reindex(
211                                    pending_reindex,
212                                    workspace,
213                                    Duration::from_millis(750),
214                                );
215                            }
216                        }
217                    } else {
218                        eprintln!(
219                            "ontocore-lsp: rejected document change: invalid edit range for {}",
220                            params.text_document.uri.as_str()
221                        );
222                    }
223                }
224            }
225        }
226        DidCloseTextDocument::METHOD => {
227            if let Ok(params) =
228                serde_json::from_value::<lsp_types::DidCloseTextDocumentParams>(notif.params)
229            {
230                if let Ok(path) = document_path_from_uri(state, &params.text_document.uri) {
231                    state.remove_document(&path);
232                }
233            }
234            if let Some(workspace) = state.effective_index_root() {
235                schedule_reindex(pending_reindex, workspace, Duration::from_millis(750));
236            }
237        }
238        "workspace/didChangeWorkspaceFolders" => {
239            if let Ok(params) =
240                serde_json::from_value::<lsp_types::DidChangeWorkspaceFoldersParams>(notif.params)
241            {
242                let mut roots = state.workspace_roots();
243                for removed in params.event.removed {
244                    if let Ok(path) = resolve_workspace_folder_uri(removed.uri.as_str(), &roots) {
245                        roots.retain(|r| !canonical_roots_match(r, &path));
246                    }
247                }
248                for added in params.event.added {
249                    if let Ok(path) = resolve_workspace_folder_uri(added.uri.as_str(), &roots) {
250                        if !roots.iter().any(|r| canonical_roots_match(r, &path)) {
251                            roots.push(path);
252                        }
253                    }
254                }
255                if roots.is_empty() {
256                    state.clear_workspace_state();
257                    eprintln!("ontocore-lsp: all workspace folders removed");
258                } else if let Err(err) = state.set_workspace_roots(roots) {
259                    eprintln!("ontocore-lsp: failed to update workspace roots: {err}");
260                } else if let Some(workspace) = state.effective_index_root() {
261                    schedule_reindex(pending_reindex, workspace, Duration::from_millis(500));
262                }
263            }
264        }
265        _ => {}
266    }
267}
268
269fn publish_pending_diagnostics(
270    sender: &Sender<Message>,
271    state: &ServerState,
272    pending: &Arc<AtomicBool>,
273) {
274    if !pending.swap(false, Ordering::SeqCst) {
275        return;
276    }
277    publish_diagnostics_for_state(sender, state);
278}
279
280fn document_path_from_uri(
281    state: &ServerState,
282    uri: &lsp_types::Uri,
283) -> Result<std::path::PathBuf, String> {
284    state.resolve_lsp_document_uri(uri.as_str())
285}
286
287fn merged_document_text(
288    state: &ServerState,
289    path: &std::path::Path,
290    params: &lsp_types::DidChangeTextDocumentParams,
291) -> Option<String> {
292    let mut text = state.document_text(path)?;
293    for change in &params.content_changes {
294        if let Some(range) = &change.range {
295            text = apply_text_change(&text, range, &change.text)?;
296        } else {
297            text = change.text.clone();
298        }
299    }
300    Some(text)
301}
302
303/// Map an LSP position to a byte offset, preserving `\n` / `\r\n` / `\r` line endings.
304fn position_to_byte(text: &str, pos: lsp_types::Position) -> Option<usize> {
305    let bytes = text.as_bytes();
306    let mut line = 0u32;
307    let mut i = 0usize;
308    while line < pos.line {
309        if i >= bytes.len() {
310            return None;
311        }
312        if bytes[i] == b'\n' {
313            line += 1;
314            i += 1;
315        } else if bytes[i] == b'\r' {
316            line += 1;
317            i += 1;
318            if i < bytes.len() && bytes[i] == b'\n' {
319                i += 1;
320            }
321        } else {
322            i += 1;
323        }
324    }
325    let line_start = i;
326    let mut line_end = i;
327    while line_end < bytes.len() && bytes[line_end] != b'\n' && bytes[line_end] != b'\r' {
328        line_end += 1;
329    }
330    let line_str = text.get(line_start..line_end)?;
331    let col = utf16_offset_to_byte(line_str, pos.character).min(line_str.len());
332    let offset = line_start + col;
333    if !text.is_char_boundary(offset) {
334        return None;
335    }
336    Some(offset)
337}
338
339fn apply_text_change(text: &str, range: &lsp_types::Range, new_text: &str) -> Option<String> {
340    let start = position_to_byte(text, range.start)?;
341    let end = position_to_byte(text, range.end)?;
342    if start > end {
343        eprintln!("ontocore-lsp: text change range inverted (start={start}, end={end})");
344        return None;
345    }
346    let mut result = String::with_capacity(text.len() - (end - start) + new_text.len());
347    result.push_str(&text[..start]);
348    result.push_str(new_text);
349    result.push_str(&text[end..]);
350    Some(result)
351}
352
353fn schedule_reindex(
354    pending: &Arc<Mutex<Option<PendingReindex>>>,
355    workspace: std::path::PathBuf,
356    delay: Duration,
357) {
358    if let Ok(mut guard) = pending.lock() {
359        *guard = Some(PendingReindex { workspace, scheduled_at: Instant::now() + delay });
360    }
361}
362
363fn take_due_reindex_workspace(
364    pending: &Arc<Mutex<Option<PendingReindex>>>,
365) -> Option<std::path::PathBuf> {
366    let Ok(mut guard) = pending.lock() else {
367        return None;
368    };
369    let entry = guard.as_ref()?;
370    if Instant::now() < entry.scheduled_at {
371        return None;
372    }
373    let workspace = entry.workspace.clone();
374    *guard = None;
375    Some(workspace)
376}
377
378fn parse_params<T: serde::de::DeserializeOwned>(params: Option<Value>) -> Result<T, ResponseError> {
379    serde_json::from_value(params.unwrap_or(Value::Null)).map_err(|e| ResponseError {
380        code: -32602,
381        message: format!("invalid params: {e}"),
382        data: None,
383    })
384}
385
386fn ok_response(id: RequestId, result: impl serde::Serialize) -> Response {
387    Response { id, result: Some(serde_json::to_value(result).unwrap_or(Value::Null)), error: None }
388}
389
390fn error_response(id: RequestId, error: ResponseError) -> Response {
391    Response { id, result: None, error: Some(error) }
392}
393
394fn ontocore_error_response(id: RequestId, err: protocol::LspErrorPayload) -> Response {
395    Response {
396        id,
397        result: None,
398        error: Some(ResponseError {
399            code: -32000,
400            message: err.message.clone(),
401            data: Some(serde_json::to_value(err).unwrap_or(Value::Null)),
402        }),
403    }
404}
405
406#[cfg(test)]
407mod debounce_tests {
408    use super::*;
409    use std::path::PathBuf;
410
411    #[test]
412    fn take_due_clears_pending_after_delay() {
413        let pending: Arc<Mutex<Option<PendingReindex>>> = Arc::new(Mutex::new(None));
414        let ws = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
415        schedule_reindex(&pending, ws.clone(), Duration::from_millis(30));
416        assert!(pending.lock().unwrap().is_some());
417        thread::sleep(Duration::from_millis(60));
418        let taken = take_due_reindex_workspace(&pending);
419        assert_eq!(taken, Some(ws));
420        assert!(pending.lock().unwrap().is_none());
421    }
422}