Skip to main content

eure_ls/
lib.rs

1//! Eure Language Server - LSP implementation for the Eure data format.
2//!
3//! This crate provides both a native binary (`eurels`) and a WASM module
4//! for use in VS Code web extensions.
5
6mod capabilities;
7pub mod queries;
8pub mod types;
9mod uri_utils;
10
11// Native-specific module (non-WASM)
12#[cfg(not(target_arch = "wasm32"))]
13pub mod native;
14
15// WASM-specific module
16#[cfg(target_arch = "wasm32")]
17mod wasm;
18#[cfg(target_arch = "wasm32")]
19pub use wasm::WasmCore;
20
21// Public exports for shared functionality
22pub use capabilities::server_capabilities;
23pub use queries::{
24    LspDiagnostics, LspFileDiagnostics, LspSemanticTokens, lsp_completion, lsp_definition,
25    lsp_hover, position_to_offset,
26};
27pub use types::{CoreRequestId, Effect, LspError, LspOutput};
28
29use std::collections::{HashMap, HashSet};
30use std::path::PathBuf;
31
32use eure::query::{
33    CollectDiagnosticTargets, Glob, GlobResult, OpenDocuments, OpenDocumentsList, TextFile,
34    TextFileContent, Workspace, WorkspaceId, build_runtime,
35};
36use lsp_types::InitializeParams;
37use query_flow::{DurabilityLevel, QueryRuntime};
38
39use crate::types::{
40    CommandQuery, CommandResult, CompletionRequest, DefinitionRequest, FileDiagnosticsSubscription,
41    HoverRequest, PendingRequest,
42};
43use crate::uri_utils::uri_to_text_file;
44
45use lsp_types::{
46    CompletionParams, CompletionResponse, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
47    DidOpenTextDocumentParams, HoverParams, InitializeResult, PublishDiagnosticsParams,
48    SemanticTokensParams,
49    notification::{
50        DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument,
51        Notification as LspNotification, PublishDiagnostics,
52    },
53    request::{
54        Completion, HoverRequest as HoverLspRequest, Initialize, Request as LspRequest,
55        SemanticTokensFullRequest, Shutdown,
56    },
57};
58
59use crate::uri_utils::text_file_to_uri;
60use query_flow::{Db, QueryError};
61use serde_json::Value;
62
63// Cross-platform logging
64#[cfg(not(target_arch = "wasm32"))]
65use tracing::{debug, error};
66
67#[cfg(target_arch = "wasm32")]
68macro_rules! debug {
69    ($($arg:tt)*) => { web_sys::console::debug_1(&format!($($arg)*).into()) };
70}
71#[cfg(target_arch = "wasm32")]
72macro_rules! error {
73    ($($arg:tt)*) => { web_sys::console::error_1(&format!($($arg)*).into()) };
74}
75
76/// Register workspaces from LSP initialization parameters.
77pub fn register_workspaces_from_init(runtime: &mut QueryRuntime, params: &InitializeParams) {
78    if let Some(folders) = &params.workspace_folders {
79        for folder in folders {
80            let workspace_path = PathBuf::from(folder.uri.path().as_str());
81            let config_path = workspace_path.join("Eure.eure");
82
83            runtime.resolve_asset(
84                WorkspaceId(workspace_path.to_string_lossy().into_owned()),
85                Workspace {
86                    path: workspace_path,
87                    config_path,
88                },
89                DurabilityLevel::Static,
90            );
91        }
92    } else if let Some(root_uri) = {
93        #[allow(
94            deprecated,
95            reason = "fallback for clients without workspace_folders support"
96        )]
97        &params.root_uri
98    } {
99        let workspace_path = PathBuf::from(root_uri.path().as_str());
100        let config_path = workspace_path.join("Eure.eure");
101
102        runtime.resolve_asset(
103            WorkspaceId(workspace_path.to_string_lossy().into_owned()),
104            Workspace {
105                path: workspace_path,
106                config_path,
107            },
108            DurabilityLevel::Static,
109        );
110    }
111}
112
113/// The headless LSP core state machine.
114///
115/// This struct contains all the state and logic for the language server,
116/// independent of the platform-specific event loop. Both native and WASM
117/// implementations use this core.
118pub struct LspCore {
119    /// The query runtime for executing LSP queries.
120    runtime: QueryRuntime,
121    /// Pending requests waiting for assets to be resolved.
122    pending_requests: HashMap<CoreRequestId, PendingRequest>,
123    /// Files that have been requested but not yet resolved.
124    pending_assets: HashSet<TextFile>,
125    /// Glob patterns that have been requested but not yet resolved.
126    pending_globs: HashMap<String, Glob>,
127    /// Per-file diagnostics subscriptions with revision tracking.
128    diagnostics_subscriptions: HashMap<TextFile, FileDiagnosticsSubscription>,
129    /// URIs we've published diagnostics to (for stale clearing).
130    published_uris: HashSet<String>,
131    /// Cached content of open documents (keyed by URI string).
132    documents: HashMap<String, String>,
133    /// Whether the server has been initialized.
134    initialized: bool,
135    definition_link_support: bool,
136}
137
138impl LspCore {
139    /// Create a new LspCore instance.
140    pub fn new() -> Self {
141        let runtime = build_runtime();
142
143        Self {
144            runtime,
145            pending_requests: HashMap::new(),
146            pending_assets: HashSet::new(),
147            pending_globs: HashMap::new(),
148            diagnostics_subscriptions: HashMap::new(),
149            published_uris: HashSet::new(),
150            documents: HashMap::new(),
151            initialized: false,
152            definition_link_support: false,
153        }
154    }
155
156    /// Get a mutable reference to the query runtime.
157    ///
158    /// This is useful for registering workspaces during initialization.
159    pub fn runtime_mut(&mut self) -> &mut QueryRuntime {
160        &mut self.runtime
161    }
162
163    /// Check if the server has been initialized.
164    pub fn is_initialized(&self) -> bool {
165        self.initialized
166    }
167
168    /// Apply capabilities for both the native and WASM initialization paths.
169    pub fn configure_client(&mut self, capabilities: &lsp_types::ClientCapabilities) {
170        self.definition_link_support = capabilities
171            .text_document
172            .as_ref()
173            .and_then(|caps| caps.definition.as_ref())
174            .and_then(|caps| caps.link_support)
175            .unwrap_or(false);
176    }
177
178    /// Mark the server as initialized.
179    pub fn set_initialized(&mut self) {
180        self.initialized = true;
181    }
182
183    /// Get pending files that need to be fetched.
184    pub fn pending_files(&self) -> impl Iterator<Item = &TextFile> {
185        self.pending_assets.iter()
186    }
187
188    /// Get pending glob patterns that need to be expanded.
189    pub fn pending_globs(&self) -> impl Iterator<Item = (&str, &Glob)> {
190        self.pending_globs.iter().map(|(k, v)| (k.as_str(), v))
191    }
192
193    // === Document Management ===
194
195    /// Update the OpenDocuments asset with current open documents.
196    ///
197    /// This should be called whenever documents are opened or closed to ensure
198    /// collection queries (`CollectDiagnosticTargets`, `CollectSchemaFiles`) are invalidated.
199    fn update_open_documents(&mut self) {
200        let files: Vec<TextFile> = self
201            .documents
202            .keys()
203            .filter_map(|uri| uri_to_text_file(uri).ok())
204            .collect();
205
206        self.runtime.resolve_asset(
207            OpenDocuments,
208            OpenDocumentsList(files),
209            DurabilityLevel::Volatile,
210        );
211    }
212
213    /// Open a document and cache its content.
214    ///
215    /// This should be called when a `textDocument/didOpen` notification is received.
216    pub fn open_document(&mut self, uri: &str, content: String) {
217        // Update document cache
218        self.documents.insert(uri.to_string(), content.clone());
219
220        // Resolve in query runtime
221        let Ok(file) = uri_to_text_file(uri) else {
222            return; // Invalid URI - skip
223        };
224        self.runtime
225            .resolve_asset(file, TextFileContent(content), DurabilityLevel::Volatile);
226
227        // Update open documents asset
228        self.update_open_documents();
229    }
230
231    /// Update a document's content.
232    ///
233    /// This should be called when a `textDocument/didChange` notification is received.
234    pub fn change_document(&mut self, uri: &str, content: String) {
235        // Same as open - we use full sync mode
236        self.open_document(uri, content);
237    }
238
239    /// Close a document and clear its cached content.
240    ///
241    /// This should be called when a `textDocument/didClose` notification is received.
242    pub fn close_document(&mut self, uri: &str) {
243        // Remove from document cache
244        self.documents.remove(uri);
245
246        // Invalidate in query runtime
247        if let Ok(file) = uri_to_text_file(uri) {
248            // Downloaded sources remain authoritative when their read-only
249            // editor closes; reopening must not independently refetch them.
250            if file.as_local_path().is_some() {
251                self.runtime.invalidate_asset(&file);
252            }
253        }
254
255        // Update open documents asset - this triggers re-evaluation of diagnostic targets
256        self.update_open_documents();
257    }
258
259    /// Get the cached content of a document.
260    pub fn get_document(&self, uri: &str) -> Option<&String> {
261        self.documents.get(uri)
262    }
263
264    // === Request Handling ===
265
266    /// Handle an LSP request.
267    ///
268    /// Returns outputs to send to the client and effects for the platform to perform.
269    pub fn handle_request(
270        &mut self,
271        id: CoreRequestId,
272        method: &str,
273        params: Value,
274    ) -> (Vec<LspOutput>, Vec<Effect>) {
275        let mut outputs = Vec::new();
276        let mut effects = Vec::new();
277
278        match method {
279            Initialize::METHOD => {
280                let init_params: InitializeParams = match serde_json::from_value(params) {
281                    Ok(p) => p,
282                    Err(e) => {
283                        outputs.push(LspOutput::Response {
284                            id,
285                            result: Err(LspError::invalid_params(format!("Invalid params: {}", e))),
286                        });
287                        return (outputs, effects);
288                    }
289                };
290
291                self.configure_client(&init_params.capabilities);
292
293                // Register workspaces from initialization
294                register_workspaces_from_init(&mut self.runtime, &init_params);
295
296                let result = InitializeResult {
297                    capabilities: server_capabilities(),
298                    server_info: Some(lsp_types::ServerInfo {
299                        name: "eure-ls".to_string(),
300                        version: Some(env!("CARGO_PKG_VERSION").to_string()),
301                    }),
302                };
303
304                self.initialized = true;
305                outputs.push(LspOutput::Response {
306                    id,
307                    result: Ok(serde_json::to_value(result).unwrap()),
308                });
309            }
310            Shutdown::METHOD => {
311                outputs.push(LspOutput::Response {
312                    id,
313                    result: Ok(Value::Null),
314                });
315            }
316            SemanticTokensFullRequest::METHOD => {
317                let params: SemanticTokensParams = match serde_json::from_value(params) {
318                    Ok(p) => p,
319                    Err(e) => {
320                        outputs.push(LspOutput::Response {
321                            id,
322                            result: Err(LspError::invalid_params(format!("Invalid params: {}", e))),
323                        });
324                        return (outputs, effects);
325                    }
326                };
327
328                let uri = params.text_document.uri;
329                let uri_str = uri.as_str();
330                let file = match uri_to_text_file(uri_str) {
331                    Ok(f) => f,
332                    Err(e) => {
333                        outputs.push(LspOutput::Response {
334                            id,
335                            result: Err(LspError::invalid_params(format!("Invalid URI: {}", e))),
336                        });
337                        return (outputs, effects);
338                    }
339                };
340                let source = self.documents.get(uri_str).cloned().unwrap_or_default();
341
342                let query = LspSemanticTokens::new(file, source.clone());
343                let command = CommandQuery::SemanticTokensFull(query);
344                let (cmd_outputs, cmd_effects) = self.run_command(id, command);
345                outputs.extend(cmd_outputs);
346                effects.extend(cmd_effects);
347            }
348            Completion::METHOD => {
349                let params: CompletionParams = match serde_json::from_value(params) {
350                    Ok(p) => p,
351                    Err(e) => {
352                        outputs.push(LspOutput::Response {
353                            id,
354                            result: Err(LspError::invalid_params(format!("Invalid params: {}", e))),
355                        });
356                        return (outputs, effects);
357                    }
358                };
359
360                let position = params.text_document_position;
361                let uri_str = position.text_document.uri.as_str();
362                let file = match uri_to_text_file(uri_str) {
363                    Ok(f) => f,
364                    Err(e) => {
365                        outputs.push(LspOutput::Response {
366                            id,
367                            result: Err(LspError::invalid_params(format!("Invalid URI: {}", e))),
368                        });
369                        return (outputs, effects);
370                    }
371                };
372                // The cursor is converted against the text we last received for
373                // this document, which is exactly what the queries parse.
374                let source = self.documents.get(uri_str).cloned().unwrap_or_default();
375                let offset = position_to_offset(&source, position.position) as u32;
376
377                let command = CommandQuery::Completion(CompletionRequest { file, offset });
378                let (cmd_outputs, cmd_effects) = self.run_command(id, command);
379                outputs.extend(cmd_outputs);
380                effects.extend(cmd_effects);
381            }
382            HoverLspRequest::METHOD => {
383                let params: HoverParams = match serde_json::from_value(params) {
384                    Ok(p) => p,
385                    Err(e) => {
386                        outputs.push(LspOutput::Response {
387                            id,
388                            result: Err(LspError::invalid_params(format!("Invalid params: {}", e))),
389                        });
390                        return (outputs, effects);
391                    }
392                };
393
394                let position = params.text_document_position_params;
395                let uri_str = position.text_document.uri.as_str();
396                let file = match uri_to_text_file(uri_str) {
397                    Ok(f) => f,
398                    Err(e) => {
399                        outputs.push(LspOutput::Response {
400                            id,
401                            result: Err(LspError::invalid_params(format!("Invalid URI: {}", e))),
402                        });
403                        return (outputs, effects);
404                    }
405                };
406                let source = self.documents.get(uri_str).cloned().unwrap_or_default();
407                let offset = position_to_offset(&source, position.position) as u32;
408
409                let command = CommandQuery::Hover(HoverRequest { file, offset });
410                let (cmd_outputs, cmd_effects) = self.run_command(id, command);
411                outputs.extend(cmd_outputs);
412                effects.extend(cmd_effects);
413            }
414            "textDocument/definition" => {
415                let params: lsp_types::GotoDefinitionParams = match serde_json::from_value(params) {
416                    Ok(p) => p,
417                    Err(e) => {
418                        outputs.push(LspOutput::Response {
419                            id,
420                            result: Err(LspError::invalid_params(format!("Invalid params: {}", e))),
421                        });
422                        return (outputs, effects);
423                    }
424                };
425
426                let position = params.text_document_position_params;
427                let uri_str = position.text_document.uri.as_str();
428                let file = match uri_to_text_file(uri_str) {
429                    Ok(f) => f,
430                    Err(e) => {
431                        outputs.push(LspOutput::Response {
432                            id,
433                            result: Err(LspError::invalid_params(format!("Invalid URI: {}", e))),
434                        });
435                        return (outputs, effects);
436                    }
437                };
438                let command = CommandQuery::Definition(DefinitionRequest {
439                    file,
440                    position: position.position,
441                });
442                let (cmd_outputs, cmd_effects) = self.run_command(id, command);
443                outputs.extend(cmd_outputs);
444                effects.extend(cmd_effects);
445            }
446            "eure/schemaContent" => {
447                let params: lsp_types::TextDocumentIdentifier = match serde_json::from_value(params)
448                {
449                    Ok(params) => params,
450                    Err(error) => {
451                        outputs.push(LspOutput::Response {
452                            id,
453                            result: Err(LspError::invalid_params(error.to_string())),
454                        });
455                        return (outputs, effects);
456                    }
457                };
458                let file = match uri_to_text_file(params.uri.as_str()) {
459                    Ok(file) if file.as_url().is_some() => file,
460                    _ => {
461                        outputs.push(LspOutput::Response {
462                            id,
463                            result: Err(LspError::invalid_params("Expected an HTTPS schema URI")),
464                        });
465                        return (outputs, effects);
466                    }
467                };
468                let (cmd_outputs, cmd_effects) =
469                    self.run_command(id, CommandQuery::SchemaContent(file));
470                outputs.extend(cmd_outputs);
471                effects.extend(cmd_effects);
472            }
473            _ => {
474                outputs.push(LspOutput::Response {
475                    id,
476                    result: Err(LspError::method_not_found(method)),
477                });
478            }
479        }
480
481        (outputs, effects)
482    }
483
484    /// Execute a command query for request `id`, or park it until the assets
485    /// it suspended on are resolved.
486    fn run_command(
487        &mut self,
488        id: CoreRequestId,
489        command: CommandQuery,
490    ) -> (Vec<LspOutput>, Vec<Effect>) {
491        let mut outputs = Vec::new();
492        let mut effects = Vec::new();
493
494        match self.try_execute(&command) {
495            Ok(result) => {
496                let json = self.result_to_value(result);
497                outputs.push(LspOutput::Response {
498                    id,
499                    result: Ok(json),
500                });
501            }
502            Err(QueryError::Suspend { .. }) => {
503                // Query is pending - collect effects and store request
504                let (new_effects, waiting_for) = self.collect_pending_assets();
505                effects.extend(new_effects);
506
507                self.pending_requests.insert(
508                    id.clone(),
509                    PendingRequest {
510                        id,
511                        command,
512                        waiting_for,
513                    },
514                );
515            }
516            Err(e) => {
517                if let Some(lsp_err) = Self::handle_query_error(command.name(), e) {
518                    outputs.push(LspOutput::Response {
519                        id,
520                        result: Err(lsp_err),
521                    });
522                }
523            }
524        }
525
526        (outputs, effects)
527    }
528
529    /// Cancel a pending request.
530    pub fn cancel_request(&mut self, id: &CoreRequestId) {
531        self.pending_requests.remove(id);
532    }
533
534    // === Notification Handling ===
535
536    /// Handle an LSP notification.
537    ///
538    /// Returns outputs to send to the client and effects for the platform to perform.
539    pub fn handle_notification(
540        &mut self,
541        method: &str,
542        params: Value,
543    ) -> (Vec<LspOutput>, Vec<Effect>) {
544        let mut outputs = Vec::new();
545        let mut effects = Vec::new();
546
547        match method {
548            DidOpenTextDocument::METHOD => {
549                if let Ok(params) = serde_json::from_value::<DidOpenTextDocumentParams>(params) {
550                    let uri = params.text_document.uri;
551                    let content = params.text_document.text;
552
553                    // Open document in core
554                    self.open_document(uri.as_str(), content);
555
556                    // Refresh diagnostics for all targets
557                    let (diag_outputs, diag_effects) = self.refresh_diagnostics();
558                    outputs.extend(diag_outputs);
559                    effects.extend(diag_effects);
560                }
561            }
562            DidChangeTextDocument::METHOD => {
563                if let Ok(params) = serde_json::from_value::<DidChangeTextDocumentParams>(params) {
564                    let uri = params.text_document.uri;
565                    // We use FULL sync, so there's only one change with the full content
566                    if let Some(change) = params.content_changes.into_iter().next() {
567                        let content = change.text;
568
569                        // Change document in core
570                        self.change_document(uri.as_str(), content);
571
572                        // Refresh diagnostics for all targets
573                        let (diag_outputs, diag_effects) = self.refresh_diagnostics();
574                        outputs.extend(diag_outputs);
575                        effects.extend(diag_effects);
576                    }
577                }
578            }
579            DidCloseTextDocument::METHOD => {
580                if let Ok(params) = serde_json::from_value::<DidCloseTextDocumentParams>(params) {
581                    let uri = params.text_document.uri;
582                    let uri_str = uri.as_str();
583
584                    // Close document in core
585                    self.close_document(uri_str);
586
587                    // Also remove any pending requests for this document
588                    self.pending_requests
589                        .retain(|_, pending| text_file_to_uri(pending.command.file()) != uri_str);
590
591                    // Refresh diagnostics - stale files will be cleared automatically
592                    let (diag_outputs, diag_effects) = self.refresh_diagnostics();
593                    outputs.extend(diag_outputs);
594                    effects.extend(diag_effects);
595                }
596            }
597            "$/cancelRequest" => {
598                if let Some(id) = params.get("id") {
599                    let core_id = CoreRequestId::from(id);
600                    self.cancel_request(&core_id);
601                }
602            }
603            "initialized" | "exit" => {
604                // Ignore
605            }
606            _ => {
607                // Unknown notification - ignore
608            }
609        }
610
611        (outputs, effects)
612    }
613
614    /// Refresh diagnostics for all diagnostic targets.
615    ///
616    /// Uses `CollectDiagnosticTargets` to discover all files needing diagnostics,
617    /// then polls `LspFileDiagnostics` for each file with per-file revision tracking.
618    ///
619    /// Returns notifications for all changed files and any effects needed.
620    fn refresh_diagnostics(&mut self) -> (Vec<LspOutput>, Vec<Effect>) {
621        let mut outputs = Vec::new();
622        let mut effects = Vec::new();
623
624        debug!("[LspCore] refresh_diagnostics");
625
626        // 1. Collect all files to diagnose (includes open docs + schema files)
627        let all_files = match self.runtime.poll(CollectDiagnosticTargets::new()) {
628            Ok(polled) => match polled.value {
629                Ok(files) => files,
630                Err(e) => {
631                    error!("CollectDiagnosticTargets error: {}", e);
632                    return (outputs, effects);
633                }
634            },
635            Err(QueryError::Suspend { .. }) => {
636                debug!("[LspCore] CollectDiagnosticTargets suspended");
637                let (new_effects, _) = self.collect_pending_assets();
638                effects.extend(new_effects);
639                return (outputs, effects);
640            }
641            Err(e) => {
642                Self::handle_query_error("CollectDiagnosticTargets", e);
643                return (outputs, effects);
644            }
645        };
646
647        debug!("[LspCore] diagnostic targets: {} files", all_files.len());
648
649        // 2. Poll LspFileDiagnostics for each file
650        let mut current_uris = HashSet::new();
651        for file in all_files.iter() {
652            let query = LspFileDiagnostics::new(file.clone());
653
654            // Get or create subscription
655            let last_revision = self
656                .diagnostics_subscriptions
657                .get(file)
658                .map(|s| s.last_revision)
659                .unwrap_or_default();
660
661            match self.runtime.poll(query.clone()) {
662                Ok(polled) => {
663                    let uri = text_file_to_uri(file);
664                    current_uris.insert(uri.clone());
665
666                    // Only publish if revision changed
667                    if polled.revision != last_revision {
668                        // Update subscription
669                        self.diagnostics_subscriptions.insert(
670                            file.clone(),
671                            FileDiagnosticsSubscription {
672                                file: file.clone(),
673                                query,
674                                last_revision: polled.revision,
675                            },
676                        );
677
678                        match polled.value {
679                            Ok(diagnostics) => {
680                                debug!(
681                                    "[LspCore] sending {} diagnostics for {}",
682                                    diagnostics.len(),
683                                    uri
684                                );
685                                if let Ok(parsed_uri) = uri.parse::<lsp_types::Uri>() {
686                                    let params = PublishDiagnosticsParams {
687                                        uri: parsed_uri,
688                                        diagnostics: diagnostics.as_ref().clone(),
689                                        version: None,
690                                    };
691                                    outputs.push(LspOutput::Notification {
692                                        method: PublishDiagnostics::METHOD.to_string(),
693                                        params: serde_json::to_value(params).unwrap(),
694                                    });
695                                }
696                            }
697                            Err(e) => {
698                                error!("Diagnostics query error for {}: {}", uri, e);
699                                if let Ok(parsed_uri) = uri.parse::<lsp_types::Uri>() {
700                                    let params = PublishDiagnosticsParams {
701                                        uri: parsed_uri,
702                                        diagnostics: vec![],
703                                        version: None,
704                                    };
705                                    outputs.push(LspOutput::Notification {
706                                        method: PublishDiagnostics::METHOD.to_string(),
707                                        params: serde_json::to_value(params).unwrap(),
708                                    });
709                                }
710                            }
711                        }
712                    }
713                }
714                Err(QueryError::Suspend { .. }) => {
715                    debug!("[LspCore] diagnostics for {:?} suspended", file);
716                    // Store subscription for retry
717                    self.diagnostics_subscriptions.insert(
718                        file.clone(),
719                        FileDiagnosticsSubscription {
720                            file: file.clone(),
721                            query,
722                            last_revision,
723                        },
724                    );
725                    let (new_effects, _) = self.collect_pending_assets();
726                    effects.extend(new_effects);
727                }
728                Err(e) => {
729                    Self::handle_query_error(&format!("LspFileDiagnostics({:?})", file), e);
730                }
731            }
732        }
733
734        // 3. Clear stale diagnostics for files no longer in target set
735        let stale: Vec<_> = self
736            .published_uris
737            .difference(&current_uris)
738            .cloned()
739            .collect();
740        for uri in stale {
741            debug!("[LspCore] clearing stale diagnostics for {}", uri);
742            if let Ok(parsed_uri) = uri.parse::<lsp_types::Uri>() {
743                let params = PublishDiagnosticsParams {
744                    uri: parsed_uri,
745                    diagnostics: vec![],
746                    version: None,
747                };
748                outputs.push(LspOutput::Notification {
749                    method: PublishDiagnostics::METHOD.to_string(),
750                    params: serde_json::to_value(params).unwrap(),
751                });
752            }
753        }
754        self.published_uris = current_uris;
755
756        // 4. Remove subscriptions for files no longer tracked
757        self.diagnostics_subscriptions
758            .retain(|f, _| all_files.contains(f));
759
760        (outputs, effects)
761    }
762
763    // === Asset Resolution ===
764
765    /// Resolve a file asset with its content.
766    ///
767    /// Returns outputs (responses, notifications) and effects for any newly pending assets.
768    pub fn resolve_file(
769        &mut self,
770        file: TextFile,
771        content: Result<String, String>,
772    ) -> (Vec<LspOutput>, Vec<Effect>) {
773        // Resolve in runtime
774        match content {
775            Ok(text) => {
776                self.runtime.resolve_asset(
777                    file.clone(),
778                    TextFileContent(text),
779                    DurabilityLevel::Volatile,
780                );
781            }
782            Err(error) => {
783                self.runtime.resolve_asset_error::<TextFile>(
784                    file.clone(),
785                    anyhow::anyhow!("{}", error),
786                    DurabilityLevel::Volatile,
787                );
788            }
789        }
790        self.pending_assets.remove(&file);
791
792        // Process pending requests and diagnostics
793        self.process_after_asset_change()
794    }
795
796    /// Resolve a glob pattern with matching files.
797    ///
798    /// Returns outputs (responses, notifications) and effects for any newly pending assets.
799    pub fn resolve_glob(
800        &mut self,
801        id: &str,
802        files: Vec<TextFile>,
803    ) -> (Vec<LspOutput>, Vec<Effect>) {
804        if let Some(glob_key) = self.pending_globs.remove(id) {
805            self.runtime
806                .resolve_asset(glob_key, GlobResult(files), DurabilityLevel::Volatile);
807        }
808
809        // Process pending requests and diagnostics
810        self.process_after_asset_change()
811    }
812
813    /// Process pending requests and diagnostics after an asset is resolved.
814    fn process_after_asset_change(&mut self) -> (Vec<LspOutput>, Vec<Effect>) {
815        let mut outputs = Vec::new();
816        let mut effects = Vec::new();
817
818        // Retry pending requests
819        let (req_outputs, req_effects) = self.retry_pending_requests();
820        outputs.extend(req_outputs);
821        effects.extend(req_effects);
822
823        // Check diagnostics subscriptions
824        let (diag_outputs, diag_effects) = self.check_diagnostics_subscriptions();
825        outputs.extend(diag_outputs);
826        effects.extend(diag_effects);
827
828        (outputs, effects)
829    }
830
831    /// Retry pending requests after an asset was resolved.
832    fn retry_pending_requests(&mut self) -> (Vec<LspOutput>, Vec<Effect>) {
833        let mut outputs = Vec::new();
834        let mut effects = Vec::new();
835
836        let request_ids: Vec<CoreRequestId> = self.pending_requests.keys().cloned().collect();
837        let mut completed_ids = Vec::new();
838
839        for id in request_ids {
840            if let Some(pending) = self.pending_requests.get(&id) {
841                let command = pending.command.clone();
842
843                match self.try_execute(&command) {
844                    Ok(result) => {
845                        let json = self.result_to_value(result);
846                        outputs.push(LspOutput::Response {
847                            id: id.clone(),
848                            result: Ok(json),
849                        });
850                        completed_ids.push(id);
851                    }
852                    Err(QueryError::Suspend { .. }) => {
853                        // Still waiting - collect more effects
854                        let (new_effects, _) = self.collect_pending_assets();
855                        effects.extend(new_effects);
856                    }
857                    Err(e) => {
858                        if let Some(lsp_err) = Self::handle_query_error("RetryQuery", e) {
859                            outputs.push(LspOutput::Response {
860                                id: id.clone(),
861                                result: Err(lsp_err),
862                            });
863                            completed_ids.push(id);
864                        }
865                    }
866                }
867            }
868        }
869
870        for id in completed_ids {
871            self.pending_requests.remove(&id);
872        }
873
874        (outputs, effects)
875    }
876
877    /// Check diagnostics subscriptions and send updates.
878    ///
879    /// This simply calls `refresh_diagnostics` to re-poll all targets.
880    fn check_diagnostics_subscriptions(&mut self) -> (Vec<LspOutput>, Vec<Effect>) {
881        self.refresh_diagnostics()
882    }
883
884    // === Internal Helpers ===
885
886    /// Log a QueryError and convert it to an LspError.
887    /// Returns None for Suspend (should be handled separately).
888    fn handle_query_error(context: &str, err: QueryError) -> Option<LspError> {
889        match err {
890            QueryError::Suspend { .. } => None,
891            QueryError::Cancelled => {
892                error!("{}: query unexpectedly cancelled", context);
893                Some(LspError::internal_error("Query cancelled"))
894            }
895            QueryError::DependenciesRemoved { missing_keys } => {
896                error!("{}: dependencies removed: {:?}", context, missing_keys);
897                Some(LspError::internal_error("Dependencies removed"))
898            }
899            QueryError::Cycle { path } => {
900                error!("{}: query cycle: {:?}", context, path);
901                Some(LspError::internal_error(format!("Query cycle: {:?}", path)))
902            }
903            QueryError::InconsistentAssetResolution => {
904                unreachable!("InconsistentAssetResolution should not occur")
905            }
906            QueryError::UserError(e) => {
907                error!("{}: unexpected user error: {}", context, e);
908                Some(LspError::internal_error(e.to_string()))
909            }
910        }
911    }
912
913    /// Try to execute a command query.
914    fn try_execute(&mut self, command: &CommandQuery) -> Result<CommandResult, QueryError> {
915        match command {
916            CommandQuery::SemanticTokensFull(query) => {
917                let result = self.runtime.query(query.clone())?;
918                Ok(CommandResult::SemanticTokens(Some((*result).clone())))
919            }
920            CommandQuery::Completion(request) => {
921                let items = lsp_completion(&self.runtime, &request.file, request.offset)?;
922                Ok(CommandResult::Completion(items))
923            }
924            CommandQuery::Definition(request) => {
925                let source = self.runtime.asset(request.file.clone())?;
926                let offset = position_to_offset(source.get(), request.position) as u32;
927                Ok(CommandResult::Definition(lsp_definition(
928                    &self.runtime,
929                    &request.file,
930                    offset,
931                )?))
932            }
933            CommandQuery::SchemaContent(file) => Ok(CommandResult::SchemaContent(
934                self.runtime.asset(file.clone())?.get().to_string(),
935            )),
936            CommandQuery::Hover(request) => {
937                let hover = lsp_hover(&self.runtime, &request.file, request.offset)?;
938                Ok(CommandResult::Hover(hover))
939            }
940        }
941    }
942
943    /// Convert a command result to a JSON value.
944    fn result_to_value(&self, result: CommandResult) -> Value {
945        match result {
946            CommandResult::SemanticTokens(tokens) => {
947                serde_json::to_value(tokens).unwrap_or(Value::Null)
948            }
949            CommandResult::Completion(items) => {
950                serde_json::to_value(CompletionResponse::Array(items)).unwrap_or(Value::Null)
951            }
952            CommandResult::SchemaContent(content) => Value::String(content),
953            CommandResult::Definition(links) => {
954                let response = if self.definition_link_support {
955                    lsp_types::GotoDefinitionResponse::Link(links)
956                } else {
957                    lsp_types::GotoDefinitionResponse::Array(
958                        links
959                            .into_iter()
960                            .map(|link| lsp_types::Location {
961                                uri: link.target_uri,
962                                range: link.target_selection_range,
963                            })
964                            .collect(),
965                    )
966                };
967                serde_json::to_value(response).expect("definition locations are JSON serializable")
968            }
969            CommandResult::Hover(hover) => serde_json::to_value(hover).unwrap_or(Value::Null),
970        }
971    }
972
973    /// Collect pending assets and return effects for the platform to handle.
974    fn collect_pending_assets(&mut self) -> (Vec<Effect>, HashSet<TextFile>) {
975        let mut effects = Vec::new();
976        let mut waiting_for = HashSet::new();
977
978        for pending in self.runtime.pending_assets() {
979            if let Some(file) = pending.key::<TextFile>() {
980                if !self.pending_assets.contains(file) {
981                    self.pending_assets.insert(file.clone());
982                    effects.push(Effect::FetchFile(file.clone()));
983                }
984                waiting_for.insert(file.clone());
985            } else if let Some(glob_key) = pending.key::<Glob>() {
986                // Generate a unique ID for this glob request
987                let id = format!(
988                    "{}:{}",
989                    glob_key.base_dir.to_string_lossy(),
990                    glob_key.pattern
991                );
992                if !self.pending_globs.contains_key(&id) {
993                    self.pending_globs.insert(id.clone(), glob_key.clone());
994                    effects.push(Effect::ExpandGlob {
995                        id,
996                        glob: glob_key.clone(),
997                    });
998                }
999            }
1000        }
1001
1002        (effects, waiting_for)
1003    }
1004}
1005
1006impl Default for LspCore {
1007    fn default() -> Self {
1008        Self::new()
1009    }
1010}