1pub(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
23pub 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 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" {
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, ¶ms.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, ¶ms.text_document.uri) {
257 if let Some(text) = merged_document_text(state, &path, ¶ms) {
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 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, ¶ms.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 ¶ms.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
371fn 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}