Skip to main content

strixonomy_lsp/
lib.rs

1//! Language server for Strixonomy (stdio). Custom methods under `strixonomy/*`.
2//!
3//! See [docs/lsp-api.md](https://github.com/eddiethedean/strixonomy/blob/main/docs/lsp-api.md).
4//!
5//! # API stability
6//!
7//! The LSP wire format and custom `strixonomy/*` 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 strixonomy_catalog::OntologyCatalog;
22
23/// Serialize the LSP `strixonomy/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 crate::protocol::RunReasonerParams;
35use crossbeam_channel::{Receiver, Sender};
36use diagnostics::{publish_diagnostics_for_state, publish_empty_diagnostics};
37use handlers::{
38    handle_custom_request, handle_initialize, handle_run_reasoner_lsp, handle_standard_request,
39    StandardRequestOutcome,
40};
41use index_worker::IndexWorker;
42use lsp_server::{Connection, Message, Notification, Request, RequestId, Response, ResponseError};
43use lsp_types::{
44    notification::Notification as _,
45    notification::{
46        DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, Exit, Initialized,
47        ShowMessage,
48    },
49    InitializeParams, MessageType, ShowMessageParams,
50};
51use serde_json::Value;
52use state::{
53    canonical_roots_match, resolve_workspace_folder_add, resolve_workspace_folder_uri, ServerState,
54};
55use std::collections::VecDeque;
56use std::sync::atomic::{AtomicBool, Ordering};
57use std::sync::{Arc, Mutex};
58use std::thread;
59use std::time::{Duration, Instant};
60
61struct PendingReindex {
62    workspace: std::path::PathBuf,
63    scheduled_at: Instant,
64}
65
66#[derive(serde::Deserialize)]
67struct CancelNotificationParams {
68    id: RequestId,
69}
70
71pub fn run() -> Result<(), Box<dyn std::error::Error>> {
72    let (connection, io_threads) = Connection::stdio();
73    let state = ServerState::new();
74    let pending_reindex: Arc<Mutex<Option<PendingReindex>>> = Arc::new(Mutex::new(None));
75    let pending_diagnostic_publish = Arc::new(AtomicBool::new(false));
76
77    let index_worker = IndexWorker::spawn(state.clone(), connection.sender.clone());
78
79    let timer_worker = index_worker.clone();
80    let timer_pending = Arc::clone(&pending_reindex);
81    thread::spawn(move || loop {
82        thread::sleep(Duration::from_millis(100));
83        if let Some(workspace) = take_due_reindex_workspace(&timer_pending) {
84            timer_worker.enqueue(workspace);
85        }
86    });
87
88    // Messages parked while `strixonomy/runReasoner` polls for cancel (see #268).
89    let mut deferred: VecDeque<Message> = VecDeque::new();
90    loop {
91        let msg = match deferred.pop_front() {
92            Some(msg) => msg,
93            None => match connection.receiver.recv() {
94                Ok(msg) => msg,
95                Err(_) => break,
96            },
97        };
98        match msg {
99            Message::Request(req) => {
100                if connection.handle_shutdown(&req)? {
101                    break;
102                }
103                if let Some(resp) = handle_lsp_request(
104                    &state,
105                    &index_worker,
106                    &pending_diagnostic_publish,
107                    &connection.receiver,
108                    &mut deferred,
109                    req,
110                ) {
111                    connection.sender.send(Message::Response(resp))?;
112                }
113                publish_pending_diagnostics(
114                    &connection.sender,
115                    &state,
116                    &pending_diagnostic_publish,
117                );
118            }
119            Message::Notification(notif) => {
120                if notif.method == Exit::METHOD {
121                    break;
122                }
123                handle_notification(&state, &pending_reindex, &connection.sender, notif);
124                publish_pending_diagnostics(
125                    &connection.sender,
126                    &state,
127                    &pending_diagnostic_publish,
128                );
129            }
130            Message::Response(_) => {}
131        }
132    }
133
134    drop(connection);
135    io_threads.join()?;
136    Ok(())
137}
138
139fn handle_lsp_request(
140    state: &ServerState,
141    index_worker: &IndexWorker,
142    pending_diagnostic_publish: &Arc<AtomicBool>,
143    message_rx: &Receiver<Message>,
144    deferred: &mut VecDeque<Message>,
145    req: Request,
146) -> Option<Response> {
147    let id = req.id.clone();
148
149    if req.method == "initialize" {
150        let params: InitializeParams = match parse_params(Some(req.params)) {
151            Ok(p) => p,
152            Err(e) => return Some(error_response(id, e)),
153        };
154        let result = handle_initialize(state, params);
155        if state.with_catalog(|_| ()).is_some() {
156            pending_diagnostic_publish.store(true, Ordering::SeqCst);
157        }
158        return Some(ok_response(id, result));
159    }
160
161    if req.method == "shutdown" {
162        return Some(ok_response(id, Value::Null));
163    }
164
165    if req.method == "strixonomy/runReasoner" || req.method == "ontocore/runReasoner" {
166        let params: RunReasonerParams = match parse_params(Some(req.params)) {
167            Ok(p) => p,
168            Err(e) => return Some(error_response(id, e)),
169        };
170        let run_generation = state.begin_reasoner_run(req.id.clone());
171        let result = handle_run_reasoner_lsp(state, params, message_rx, deferred, run_generation);
172        state.clear_active_reasoner_request();
173        return match result {
174            Ok(value) => match serde_json::to_value(value) {
175                Ok(result) => Some(ok_response(id, result)),
176                Err(e) => Some(strixonomy_error_response(
177                    id,
178                    crate::protocol::LspErrorPayload::reasoner_failed(e.to_string()),
179                )),
180            },
181            Err(err) => Some(strixonomy_error_response(id, err)),
182        };
183    }
184
185    if crate::handlers::normalize_custom_method(&req.method).is_some() {
186        return match handle_custom_request(state, index_worker, &req.method, Some(req.params)) {
187            Ok(result) => {
188                if crate::handlers::normalize_custom_method(&req.method) == Some("indexWorkspace") {
189                    pending_diagnostic_publish.store(true, Ordering::SeqCst);
190                }
191                Some(ok_response(id, result))
192            }
193            Err(err) => Some(strixonomy_error_response(id, err)),
194        };
195    }
196
197    match handle_standard_request(state, &req.method, Some(req.params)) {
198        StandardRequestOutcome::Ok(result) => Some(ok_response(id, result)),
199        StandardRequestOutcome::MethodNotFound => Some(error_response(
200            id,
201            ResponseError {
202                code: -32601,
203                message: format!("Method not found: {}", req.method),
204                data: None,
205            },
206        )),
207        StandardRequestOutcome::InvalidParams(err) => Some(error_response(id, err)),
208        StandardRequestOutcome::LspError(err) => Some(strixonomy_error_response(id, err)),
209    }
210}
211
212fn handle_notification(
213    state: &ServerState,
214    pending_reindex: &Arc<Mutex<Option<PendingReindex>>>,
215    sender: &Sender<Message>,
216    notif: Notification,
217) {
218    match notif.method.as_str() {
219        "$/cancelRequest" => {
220            if let Ok(params) = serde_json::from_value::<CancelNotificationParams>(notif.params) {
221                state.cancel_reasoner_request(params.id);
222            }
223        }
224        Initialized::METHOD => {
225            if let Some(workspace) = state.effective_index_root() {
226                schedule_reindex(pending_reindex, workspace, Duration::from_millis(500));
227            }
228        }
229        "workspace/didChangeWatchedFiles" => {
230            if let Some(workspace) = state.effective_index_root() {
231                schedule_reindex(pending_reindex, workspace, Duration::from_millis(500));
232            }
233        }
234        DidOpenTextDocument::METHOD => {
235            if let Ok(params) =
236                serde_json::from_value::<lsp_types::DidOpenTextDocumentParams>(notif.params)
237            {
238                if let Ok(path) = document_path_from_uri(state, &params.text_document.uri) {
239                    if let Err(err) =
240                        state.set_document_text(path.clone(), params.text_document.text)
241                    {
242                        eprintln!("strixonomy-lsp: rejected open document: {err}");
243                    } else {
244                        state.set_document_version(path, params.text_document.version);
245                    }
246                }
247            }
248            if let Some(workspace) = state.effective_index_root() {
249                schedule_reindex(pending_reindex, workspace, Duration::from_millis(750));
250            }
251        }
252        DidChangeTextDocument::METHOD => {
253            if let Ok(params) =
254                serde_json::from_value::<lsp_types::DidChangeTextDocumentParams>(notif.params)
255            {
256                if let Ok(path) = document_path_from_uri(state, &params.text_document.uri) {
257                    if let Some(text) = merged_document_text(state, &path, &params) {
258                        if let Err(err) = state.set_document_text(path.clone(), text) {
259                            eprintln!("strixonomy-lsp: rejected document change: {err}");
260                        } else {
261                            state.set_document_version(path, params.text_document.version);
262                            if let Some(workspace) = state.effective_index_root() {
263                                schedule_reindex(
264                                    pending_reindex,
265                                    workspace,
266                                    Duration::from_millis(750),
267                                );
268                            }
269                        }
270                    } else {
271                        // Invalid incremental edit — drop the stale buffer and ask the user to
272                        // reopen so client/server text cannot silently diverge (#90).
273                        eprintln!(
274                            "strixonomy-lsp: rejected document change: invalid edit range for {}",
275                            params.text_document.uri.as_str()
276                        );
277                        state.remove_document(&path);
278                        let params = ShowMessageParams {
279                            typ: MessageType::ERROR,
280                            message: format!(
281                                "Strixonomy: document sync failed for {}; reopen the file to resync the language server buffer",
282                                path.display()
283                            ),
284                        };
285                        let _ = sender.send(Message::Notification(Notification {
286                            method: ShowMessage::METHOD.to_string(),
287                            params: serde_json::to_value(params).unwrap_or_default(),
288                        }));
289                    }
290                }
291            }
292        }
293        DidCloseTextDocument::METHOD => {
294            if let Ok(params) =
295                serde_json::from_value::<lsp_types::DidCloseTextDocumentParams>(notif.params)
296            {
297                if let Ok(path) = document_path_from_uri(state, &params.text_document.uri) {
298                    state.remove_document(&path);
299                }
300            }
301            if let Some(workspace) = state.effective_index_root() {
302                schedule_reindex(pending_reindex, workspace, Duration::from_millis(750));
303            }
304        }
305        "workspace/didChangeWorkspaceFolders" => {
306            if let Ok(params) =
307                serde_json::from_value::<lsp_types::DidChangeWorkspaceFoldersParams>(notif.params)
308            {
309                let mut roots = state.workspace_roots();
310                for removed in params.event.removed {
311                    if let Ok(path) = resolve_workspace_folder_uri(removed.uri.as_str(), &roots) {
312                        roots.retain(|r| !canonical_roots_match(r, &path));
313                    }
314                }
315                for added in params.event.added {
316                    if let Ok(path) = resolve_workspace_folder_add(added.uri.as_str()) {
317                        if !roots.iter().any(|r| canonical_roots_match(r, &path)) {
318                            roots.push(path);
319                        }
320                    }
321                }
322                if roots.is_empty() {
323                    let stale = state.clear_workspace_state();
324                    publish_empty_diagnostics(sender, &stale);
325                    eprintln!("strixonomy-lsp: all workspace folders removed");
326                } else if let Err(err) = state.set_workspace_roots(roots) {
327                    eprintln!("strixonomy-lsp: failed to update workspace roots: {err}");
328                } else if let Some(workspace) = state.effective_index_root() {
329                    schedule_reindex(pending_reindex, workspace, Duration::from_millis(500));
330                }
331            }
332        }
333        _ => {}
334    }
335}
336
337fn publish_pending_diagnostics(
338    sender: &Sender<Message>,
339    state: &ServerState,
340    pending: &Arc<AtomicBool>,
341) {
342    if !pending.swap(false, Ordering::SeqCst) {
343        return;
344    }
345    publish_diagnostics_for_state(sender, state);
346}
347
348fn document_path_from_uri(
349    state: &ServerState,
350    uri: &lsp_types::Uri,
351) -> Result<std::path::PathBuf, String> {
352    state.resolve_lsp_document_uri(uri.as_str())
353}
354
355fn merged_document_text(
356    state: &ServerState,
357    path: &std::path::Path,
358    params: &lsp_types::DidChangeTextDocumentParams,
359) -> Option<String> {
360    let mut text = state.document_text(path)?;
361    for change in &params.content_changes {
362        if let Some(range) = &change.range {
363            text = apply_text_change(&text, range, &change.text)?;
364        } else {
365            text = change.text.clone();
366        }
367    }
368    Some(text)
369}
370
371/// Map an LSP position to a byte offset, preserving `\n` / `\r\n` / `\r` line endings.
372fn position_to_byte(text: &str, pos: lsp_types::Position) -> Option<usize> {
373    let bytes = text.as_bytes();
374    let mut line = 0u32;
375    let mut i = 0usize;
376    while line < pos.line {
377        if i >= bytes.len() {
378            return None;
379        }
380        if bytes[i] == b'\n' {
381            line += 1;
382            i += 1;
383        } else if bytes[i] == b'\r' {
384            line += 1;
385            i += 1;
386            if i < bytes.len() && bytes[i] == b'\n' {
387                i += 1;
388            }
389        } else {
390            i += 1;
391        }
392    }
393    let line_start = i;
394    let mut line_end = i;
395    while line_end < bytes.len() && bytes[line_end] != b'\n' && bytes[line_end] != b'\r' {
396        line_end += 1;
397    }
398    let line_str = text.get(line_start..line_end)?;
399    let col = utf16_offset_to_byte(line_str, pos.character).min(line_str.len());
400    let offset = line_start + col;
401    if !text.is_char_boundary(offset) {
402        return None;
403    }
404    Some(offset)
405}
406
407fn apply_text_change(text: &str, range: &lsp_types::Range, new_text: &str) -> Option<String> {
408    let start = position_to_byte(text, range.start)?;
409    let end = position_to_byte(text, range.end)?;
410    if start > end {
411        eprintln!("strixonomy-lsp: text change range inverted (start={start}, end={end})");
412        return None;
413    }
414    let mut result = String::with_capacity(text.len() - (end - start) + new_text.len());
415    result.push_str(&text[..start]);
416    result.push_str(new_text);
417    result.push_str(&text[end..]);
418    Some(result)
419}
420
421fn schedule_reindex(
422    pending: &Arc<Mutex<Option<PendingReindex>>>,
423    workspace: std::path::PathBuf,
424    delay: Duration,
425) {
426    if let Ok(mut guard) = pending.lock() {
427        *guard = Some(PendingReindex { workspace, scheduled_at: Instant::now() + delay });
428    }
429}
430
431fn take_due_reindex_workspace(
432    pending: &Arc<Mutex<Option<PendingReindex>>>,
433) -> Option<std::path::PathBuf> {
434    let Ok(mut guard) = pending.lock() else {
435        return None;
436    };
437    let entry = guard.as_ref()?;
438    if Instant::now() < entry.scheduled_at {
439        return None;
440    }
441    let workspace = entry.workspace.clone();
442    *guard = None;
443    Some(workspace)
444}
445
446fn parse_params<T: serde::de::DeserializeOwned>(params: Option<Value>) -> Result<T, ResponseError> {
447    serde_json::from_value(params.unwrap_or(Value::Null)).map_err(|e| ResponseError {
448        code: -32602,
449        message: format!("invalid params: {e}"),
450        data: None,
451    })
452}
453
454fn ok_response(id: RequestId, result: impl serde::Serialize) -> Response {
455    Response { id, result: Some(serde_json::to_value(result).unwrap_or(Value::Null)), error: None }
456}
457
458fn error_response(id: RequestId, error: ResponseError) -> Response {
459    Response { id, result: None, error: Some(error) }
460}
461
462fn strixonomy_error_response(id: RequestId, err: protocol::LspErrorPayload) -> Response {
463    Response {
464        id,
465        result: None,
466        error: Some(ResponseError {
467            code: -32000,
468            message: err.message.clone(),
469            data: Some(serde_json::to_value(err).unwrap_or(Value::Null)),
470        }),
471    }
472}
473
474#[cfg(test)]
475mod debounce_tests {
476    use super::*;
477    use std::path::PathBuf;
478
479    #[test]
480    fn take_due_clears_pending_after_delay() {
481        let pending: Arc<Mutex<Option<PendingReindex>>> = Arc::new(Mutex::new(None));
482        let ws = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
483        schedule_reindex(&pending, ws.clone(), Duration::from_millis(30));
484        assert!(pending.lock().unwrap().is_some());
485        thread::sleep(Duration::from_millis(60));
486        let taken = take_due_reindex_workspace(&pending);
487        assert_eq!(taken, Some(ws));
488        assert!(pending.lock().unwrap().is_none());
489    }
490
491    #[test]
492    fn apply_text_change_rejects_inverted_range() {
493        let text = "ex:Person a owl:Class .\n";
494        let range = lsp_types::Range {
495            start: lsp_types::Position { line: 0, character: 10 },
496            end: lsp_types::Position { line: 0, character: 2 },
497        };
498        assert!(apply_text_change(text, &range, "X").is_none());
499    }
500}