Skip to main content

rustledger_lsp/
main_loop.rs

1//! Main event loop for the LSP server.
2//!
3//! Follows rust-analyzer's architecture:
4//! - Notifications handled synchronously (critical for correctness)
5//! - Requests dispatched to threadpool with immutable snapshots
6//! - Revision counter enables cancellation of stale requests
7
8use crate::handlers::call_hierarchy::{
9    handle_incoming_calls, handle_outgoing_calls, handle_prepare_call_hierarchy,
10};
11use crate::handlers::code_actions::{handle_code_action_resolve, handle_code_actions};
12use crate::handlers::code_lens::{handle_code_lens, handle_code_lens_resolve};
13use crate::handlers::completion::handle_completion;
14use crate::handlers::completion_resolve::handle_completion_resolve;
15use crate::handlers::declaration::handle_goto_declaration;
16use crate::handlers::definition::handle_goto_definition;
17use crate::handlers::diagnostics::all_diagnostics;
18use crate::handlers::document_color::{handle_color_presentation, handle_document_color};
19use crate::handlers::document_highlight::handle_document_highlight;
20use crate::handlers::document_links::{handle_document_link_resolve, handle_document_links};
21use crate::handlers::execute_command::handle_execute_command;
22use crate::handlers::folding::handle_folding_ranges;
23use crate::handlers::formatting::handle_formatting;
24use crate::handlers::hover::handle_hover;
25use crate::handlers::inlay_hints::{handle_inlay_hint_resolve, handle_inlay_hints};
26use crate::handlers::linked_editing::handle_linked_editing_range;
27use crate::handlers::on_type_formatting::handle_on_type_formatting;
28use crate::handlers::range_formatting::handle_range_formatting;
29use crate::handlers::references::handle_references;
30use crate::handlers::rename::{handle_prepare_rename, handle_rename};
31use crate::handlers::selection_range::handle_selection_range;
32use crate::handlers::semantic_tokens::{
33    handle_semantic_tokens, handle_semantic_tokens_delta, handle_semantic_tokens_range,
34};
35use crate::handlers::signature_help::handle_signature_help;
36use crate::handlers::symbols::handle_document_symbols;
37use crate::handlers::type_hierarchy::{
38    handle_prepare_type_hierarchy, handle_subtypes, handle_supertypes,
39};
40use crate::handlers::workspace_symbols::handle_workspace_symbols;
41use crate::ledger_state::{SharedLedgerState, new_shared_ledger_state};
42use crate::uri_to_path;
43use crate::vfs::Vfs;
44use crossbeam_channel::{Receiver, Sender};
45use lsp_types::notification::{
46    DidChangeTextDocument, DidChangeWatchedFiles, DidCloseTextDocument, DidOpenTextDocument,
47    Notification, PublishDiagnostics,
48};
49use lsp_types::request::{
50    CallHierarchyIncomingCalls, CallHierarchyOutgoingCalls, CallHierarchyPrepare,
51    CodeActionRequest, CodeActionResolveRequest, CodeLensRequest, CodeLensResolve,
52    ColorPresentationRequest, Completion, DocumentColor, DocumentHighlightRequest,
53    DocumentLinkRequest, DocumentLinkResolve, DocumentSymbolRequest, ExecuteCommand,
54    FoldingRangeRequest, Formatting, GotoDeclaration, GotoDefinition, HoverRequest, Initialize,
55    InlayHintRequest, InlayHintResolveRequest, LinkedEditingRange, OnTypeFormatting,
56    PrepareRenameRequest, RangeFormatting, References, Rename, Request, ResolveCompletionItem,
57    SelectionRangeRequest, SemanticTokensFullDeltaRequest, SemanticTokensFullRequest,
58    SemanticTokensRangeRequest, Shutdown, SignatureHelpRequest, TypeHierarchyPrepare,
59    TypeHierarchySubtypes, TypeHierarchySupertypes, WorkspaceSymbolRequest,
60};
61use lsp_types::{
62    CallHierarchyIncomingCallsParams, CallHierarchyOutgoingCallsParams, CallHierarchyPrepareParams,
63    CodeAction, CodeActionParams, CodeLens, CodeLensParams, ColorPresentationParams,
64    CompletionItem, CompletionParams, DocumentColorParams, DocumentFormattingParams,
65    DocumentHighlightParams, DocumentLink, DocumentLinkParams, DocumentOnTypeFormattingParams,
66    DocumentRangeFormattingParams, DocumentSymbolParams, ExecuteCommandParams, FoldingRangeParams,
67    GotoDefinitionParams, HoverParams, InitializeParams, InlayHint, InlayHintParams,
68    LinkedEditingRangeParams, PublishDiagnosticsParams, ReferenceParams, RenameParams,
69    SelectionRangeParams, SemanticTokensDeltaParams, SemanticTokensParams,
70    SemanticTokensRangeParams, SignatureHelpParams, TextDocumentPositionParams,
71    TypeHierarchyPrepareParams, TypeHierarchySubtypesParams, TypeHierarchySupertypesParams, Uri,
72    WorkspaceSymbolParams,
73};
74use parking_lot::RwLock;
75use rustledger_core::Directive;
76use rustledger_parser::{ParseResult, Spanned, parse};
77use std::collections::HashMap;
78use std::path::PathBuf;
79use std::sync::Arc;
80
81/// Events processed by the main loop.
82#[derive(Debug)]
83pub enum Event {
84    /// LSP message from the client.
85    Message(Message),
86    /// Response from a background task (dispatched via threadpool).
87    Task(TaskResult),
88}
89
90/// LSP message types.
91#[derive(Debug)]
92pub enum Message {
93    /// Request from client (expects response).
94    Request(lsp_server::Request),
95    /// Notification from client (no response).
96    Notification(lsp_server::Notification),
97    /// Response from client (for server-initiated requests).
98    Response(lsp_server::Response),
99}
100
101/// Result from a background task.
102#[derive(Debug)]
103pub struct TaskResult {
104    /// The request ID this task is responding to.
105    pub request_id: lsp_server::RequestId,
106    /// The result of the task, or an error message.
107    pub result: Result<serde_json::Value, String>,
108    /// Set when the world changed (revision bumped) between dispatch and
109    /// completion: the result is stale and is reported to the client as
110    /// `ContentModified` instead of being silently dropped (which would leave a
111    /// strict client waiting forever for a response that never comes).
112    pub content_modified: bool,
113}
114
115/// LSP `ContentModified` error code (the document changed while the request was
116/// in flight). Defined in LSP but not exposed by `lsp_server::ErrorCode`.
117const CONTENT_MODIFIED: i32 = -32801;
118
119/// A job to be executed on the background worker thread.
120type BackgroundJob = Box<dyn FnOnce() + Send>;
121
122/// Structured failure reasons emitted by the request-dispatch loop.
123///
124/// Each variant maps deterministically to an LSP `ErrorCode` via
125/// [`DispatchError::error_code`]. Round-20 introduced this enum to
126/// replace the prior error-message-prefix routing
127/// (`msg.starts_with("Unhandled request")` etc.) - that worked, but
128/// silently coupled the dispatcher's routing decisions to the exact
129/// wording of handler error strings, so a future handler whose
130/// `Err` happened to start with a reserved prefix would have been
131/// misrouted to the wrong wire error code.
132#[derive(Debug)]
133enum DispatchError {
134    /// The request's `method` is not implemented by this server.
135    /// Maps to [`lsp_server::ErrorCode::MethodNotFound`].
136    MethodNotFound(String),
137    /// A second `initialize` request reached the dispatcher. Per LSP
138    /// 3.17 §Lifecycle, `initialize` MUST be sent exactly once; the
139    /// first one is consumed by `server.rs::start_stdio` before the
140    /// main loop runs, so any `initialize` reaching this dispatcher
141    /// is a client-side protocol violation. Maps to
142    /// [`lsp_server::ErrorCode::InvalidRequest`].
143    DuplicateInitialize,
144    /// A handler returned `Err(_)` for any other reason (parse error,
145    /// IO failure, etc.). Maps to
146    /// [`lsp_server::ErrorCode::InternalError`].
147    Handler(String),
148}
149
150impl DispatchError {
151    /// LSP wire error code for this dispatch failure.
152    fn error_code(&self) -> lsp_server::ErrorCode {
153        match self {
154            Self::MethodNotFound(_) => lsp_server::ErrorCode::MethodNotFound,
155            Self::DuplicateInitialize => lsp_server::ErrorCode::InvalidRequest,
156            Self::Handler(_) => lsp_server::ErrorCode::InternalError,
157        }
158    }
159}
160
161impl std::fmt::Display for DispatchError {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        match self {
164            Self::MethodNotFound(method) => write!(f, "Unhandled request: {method}"),
165            Self::DuplicateInitialize => write!(
166                f,
167                "initialize must be sent exactly once per LSP connection (LSP 3.17 \
168                 §Lifecycle); this connection has already been initialized via \
169                 server.rs::start_stdio."
170            ),
171            Self::Handler(msg) => f.write_str(msg),
172        }
173    }
174}
175
176/// State managed by the main loop.
177pub struct MainLoopState {
178    /// Virtual file system for open documents.
179    pub vfs: Arc<RwLock<Vfs>>,
180    /// Sender for outgoing LSP messages.
181    pub sender: Sender<lsp_server::Message>,
182    /// Cached diagnostics per file.
183    pub diagnostics: HashMap<Uri, Vec<lsp_types::Diagnostic>>,
184    /// URIs of *unopened* ledger files (reachable via `include`) that we last
185    /// published non-empty diagnostics for. Tracked so that, when their errors
186    /// are fixed, we send an explicit empty diagnostic set to clear them — the
187    /// LSP spec has no implicit "clear", and these files have no open buffer of
188    /// their own to drive a clear via `on_did_close`.
189    pub cross_file_diag_uris: std::collections::HashSet<Uri>,
190    /// Whether shutdown was requested.
191    pub shutdown_requested: bool,
192    /// Set by the `exit` notification handler. The main loop checks
193    /// this after each event and breaks when it's `Some(_)`, returning
194    /// the code from `run_main_loop_with_exit_action` so the caller
195    /// (`server.rs::start_stdio` in production) can drain the writer
196    /// thread via `io_threads.join()` BEFORE `process::exit`. Without
197    /// this, the production exit_action was `process::exit(code)`
198    /// directly - which terminates the process before the writer
199    /// thread flushes the shutdown response queued in its channel.
200    /// On a slow CI runner the writer can't keep up, the test
201    /// observes only the initialize response on stdout, and
202    /// `stdio_smoke` fails. See the `handle_notification` "exit"
203    /// arm and `run_main_loop_with_exit_action`'s return-value
204    /// documentation.
205    pub pending_exit_code: Option<i32>,
206    /// LSP position encoding negotiated at initialization (UTF-8 or
207    /// UTF-16). Handler code emitting `Position`s must consult this
208    /// so positions align with what the client expects.
209    pub position_encoding: crate::handlers::utils::PositionEncoding,
210    /// Full ledger state (loaded from journal file if configured).
211    pub ledger_state: SharedLedgerState,
212    /// Path to the journal file (if configured).
213    pub journal_file: Option<PathBuf>,
214    /// Channel for receiving results from background tasks.
215    pub task_sender: Sender<TaskResult>,
216    /// Receiver end of the task channel (used by run_main_loop).
217    pub task_receiver: Receiver<TaskResult>,
218    /// Channel for submitting jobs to the background worker thread.
219    pub job_sender: Sender<BackgroundJob>,
220    /// Per-instance revision counter for stale-result detection in
221    /// `dispatch_async`. Pre-PR #1261 this was a process-wide static
222    /// in `snapshot.rs`, which broke when multiple `MainLoopState`s
223    /// shared a process: the integration test harness spawns many
224    /// in-process LSP servers in parallel, and a `didChange` in test
225    /// A would bump the revision such that test B's pending async
226    /// result was silently discarded as stale even though B's world
227    /// hadn't changed. Tests would then time out waiting for the
228    /// dropped response. An `Arc<AtomicU64>` keeps clone-and-share
229    /// cheap so worker closures can capture without borrowing
230    /// `MainLoopState`.
231    revision: Arc<std::sync::atomic::AtomicU64>,
232    /// Action invoked when the `exit` notification arrives. Production
233    /// wires this to [`std::process::exit`]; tests pass a no-op so the
234    /// notification breaks the loop cleanly without terminating the
235    /// cargo-test process. `FnOnce` because `exit` is the terminal
236    /// notification of an LSP session.
237    ///
238    /// Defaults to `process::exit` so existing constructions continue
239    /// to behave identically; override via [`Self::with_exit_action`]
240    /// or [`run_main_loop_with_exit_action`].
241    exit_action: Option<Box<dyn FnOnce(i32) + Send>>,
242}
243
244/// Default empty parse result for missing documents.
245fn empty_parse_result() -> Arc<ParseResult> {
246    Arc::new(parse(""))
247}
248
249impl MainLoopState {
250    /// Create a new main loop state.
251    pub fn new(sender: Sender<lsp_server::Message>, journal_file: Option<PathBuf>) -> Self {
252        let ledger_state = new_shared_ledger_state();
253
254        // Load journal file if configured
255        if let Some(ref path) = journal_file {
256            let mut state = ledger_state.write();
257            if let Err(e) = state.load(path) {
258                tracing::error!("Failed to load journal file: {e}");
259            }
260        }
261
262        let (task_sender, task_receiver) = crossbeam_channel::unbounded();
263        let (job_sender, job_receiver) = crossbeam_channel::unbounded::<BackgroundJob>();
264
265        // Spawn a single persistent worker thread for background requests.
266        // Using one thread avoids the overhead of thread-per-request while
267        // still keeping the main loop unblocked. Jobs are processed FIFO;
268        // stale results are discarded via revision-based cancellation.
269        std::thread::Builder::new()
270            .name("lsp-worker".into())
271            .spawn(move || {
272                for job in job_receiver {
273                    job();
274                }
275            })
276            .expect("failed to spawn LSP worker thread");
277
278        Self {
279            vfs: Arc::new(RwLock::new(Vfs::new())),
280            cross_file_diag_uris: std::collections::HashSet::new(),
281            sender,
282            diagnostics: HashMap::new(),
283            shutdown_requested: false,
284            pending_exit_code: None,
285            // Conservative default: UTF-16 (the LSP spec default).
286            // `server.rs::run` overrides this with the negotiated
287            // encoding after `initialize`. Construction without
288            // initialize (e.g., in tests) gets the spec-safe value.
289            position_encoding: crate::handlers::utils::PositionEncoding::Utf16,
290            ledger_state,
291            journal_file,
292            task_sender,
293            task_receiver,
294            job_sender,
295            revision: Arc::new(std::sync::atomic::AtomicU64::new(0)),
296            // Production used to wire this to `process::exit(code)`,
297            // which terminated before `io_threads.join()` could drain
298            // the writer - losing the shutdown response on slow runners
299            // (the stdio_smoke flake). The exit notification now
300            // signals via `pending_exit_code` instead, and the loop
301            // breaks normally; the caller then joins io_threads and
302            // exits with the propagated code. `exit_action` is kept
303            // for side effects (test harnesses use a no-op).
304            exit_action: Some(Box::new(|_code| {})),
305        }
306    }
307
308    /// Bump the per-instance revision counter. Called whenever the
309    /// world state changes (didChange, didClose) so in-flight async
310    /// handlers can detect they should drop their results.
311    fn bump_revision(&self) -> u64 {
312        self.revision
313            .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
314            + 1
315    }
316
317    /// Replace the exit action. Returns `self` for chaining. The
318    /// default action is a no-op (process termination, if any, is the
319    /// caller's responsibility after `io_threads.join()`); tests pass a
320    /// no-op (or a flag-set closure) to avoid terminating the test
321    /// process when the `exit` notification arrives.
322    #[must_use]
323    pub fn with_exit_action<F>(mut self, action: F) -> Self
324    where
325        F: FnOnce(i32) + Send + 'static,
326    {
327        self.exit_action = Some(Box::new(action));
328        self
329    }
330
331    /// Reload the journal file (e.g., after a file change).
332    pub fn reload_journal(&mut self) {
333        if let Some(ref path) = self.journal_file {
334            let mut state = self.ledger_state.write();
335            if let Err(e) = state.load(path) {
336                tracing::error!("Failed to reload journal file: {e}");
337            }
338        }
339    }
340
341    /// Get document text and cached parse result for a URI.
342    /// Uses cached parse result if available, avoiding re-parsing.
343    fn get_document_data(&self, uri: &Uri) -> (String, Arc<ParseResult>) {
344        if let Some(path) = uri_to_path(uri)
345            && let Some((text, parse_result)) = self.vfs.write().get_document_data(&path)
346        {
347            return (text, parse_result);
348        }
349        (String::new(), empty_parse_result())
350    }
351
352    /// Handle an incoming event.
353    pub fn handle_event(&mut self, event: Event) {
354        match event {
355            Event::Message(msg) => self.handle_message(msg),
356            Event::Task(task_result) => {
357                let response = if task_result.content_modified {
358                    lsp_server::Response::new_err(
359                        task_result.request_id,
360                        CONTENT_MODIFIED,
361                        "document changed while the request was in flight; \
362                         re-request for up-to-date results"
363                            .to_string(),
364                    )
365                } else {
366                    match task_result.result {
367                        Ok(value) => lsp_server::Response::new_ok(task_result.request_id, value),
368                        Err(msg) => lsp_server::Response::new_err(
369                            task_result.request_id,
370                            lsp_server::ErrorCode::InternalError as i32,
371                            msg,
372                        ),
373                    }
374                };
375                self.send(lsp_server::Message::Response(response));
376            }
377        }
378    }
379
380    /// Dispatch a request handler to the background worker thread.
381    ///
382    /// The handler closure receives no mutable state - it should capture
383    /// any needed data (parse results, ledger state) before being moved.
384    /// The result is sent back to the main loop as a `Task` event.
385    ///
386    /// Cancellation: the revision at dispatch time is captured. If the
387    /// world state changes before the handler completes (e.g., user edits
388    /// a document), the result is silently dropped instead of being sent.
389    fn dispatch_async(
390        &self,
391        request_id: lsp_server::RequestId,
392        handler: impl FnOnce() -> Result<serde_json::Value, String> + Send + 'static,
393    ) {
394        self.dispatch_async_inner(request_id, handler, true);
395    }
396
397    /// Same as [`Self::dispatch_async`] but skips the stale-result
398    /// check. Use for handlers whose output does not depend on any
399    /// world state (so "stale" and "fresh" produce the same answer).
400    ///
401    /// The staleness check exists to drop results computed against
402    /// outdated parse / ledger snapshots; for a stateless handler
403    /// (today: [`handle_code_lens_resolve`]'s defensive fallback,
404    /// which does nothing more than fill `command` on a `None` lens)
405    /// the check is pure overhead AND, critically, a correctness
406    /// hazard when multiple `MainLoopState`s share a process - the
407    /// revision counter is a process-wide `AtomicU64`, so parallel
408    /// instances (e.g., the integration test harness) clobber each
409    /// other's dispatch revisions and lose stateless results that
410    /// have nothing to do with the world state.
411    fn dispatch_async_unconditional(
412        &self,
413        request_id: lsp_server::RequestId,
414        handler: impl FnOnce() -> Result<serde_json::Value, String> + Send + 'static,
415    ) {
416        self.dispatch_async_inner(request_id, handler, false);
417    }
418
419    fn dispatch_async_inner(
420        &self,
421        request_id: lsp_server::RequestId,
422        handler: impl FnOnce() -> Result<serde_json::Value, String> + Send + 'static,
423        check_staleness: bool,
424    ) {
425        let task_sender = self.task_sender.clone();
426        // Capture the per-instance revision counter via Arc clone so
427        // the worker can compare without borrowing `MainLoopState`.
428        // Using the per-instance counter (not the global one in
429        // `snapshot.rs`) is required for correctness when multiple
430        // MainLoopStates share a process - see the `revision` field
431        // rustdoc.
432        let revision_arc = self.revision.clone();
433        let dispatch_revision = if check_staleness {
434            Some(revision_arc.load(std::sync::atomic::Ordering::SeqCst))
435        } else {
436            None
437        };
438
439        let _ = self.job_sender.send(Box::new(move || {
440            let result = handler();
441
442            // If the world changed since dispatch, the result is stale.
443            // Report ContentModified rather than dropping it: a strict client
444            // tracking pending requests would otherwise wait forever for a
445            // response that never arrives. It re-requests on the next edit.
446            // Skipped for unconditional dispatch (stateless handlers).
447            if let Some(rev) = dispatch_revision
448                && revision_arc.load(std::sync::atomic::Ordering::SeqCst) != rev
449            {
450                tracing::debug!(
451                    "Result for request {:?} is stale (revision changed); replying ContentModified",
452                    request_id
453                );
454                // `result` is unused on the `content_modified` path (the
455                // consumer emits a ContentModified error), but use `Err` so the
456                // value never reads as a success if future code forgets the flag.
457                let _ = task_sender.send(TaskResult {
458                    request_id,
459                    result: Err("stale result (content modified)".to_string()),
460                    content_modified: true,
461                });
462                return;
463            }
464
465            // Ignore send errors - the main loop may have shut down
466            let _ = task_sender.send(TaskResult {
467                request_id,
468                result,
469                content_modified: false,
470            });
471        }));
472    }
473
474    /// Try to dispatch an expensive request to the background worker.
475    ///
476    /// Returns `true` if the request was dispatched (response will arrive
477    /// as `Event::Task`), `false` if it should be handled synchronously.
478    ///
479    /// Data is eagerly snapshotted on the main thread (while locks are
480    /// cheap), then the CPU-intensive handler runs on the worker thread.
481    /// This avoids duplicating handler logic - the same handler functions
482    /// are called from both sync and async paths.
483    fn try_dispatch_async(&self, req: &lsp_server::Request) -> bool {
484        match req.method.as_str() {
485            // codeLens/resolve is now a no-op for every lens kind
486            // emitted by `handle_code_lens` (since #1253, balance
487            // lenses ship fully-resolved on the initial response).
488            // The defensive fallback only fills in `command` when a
489            // lens arrives with `command: None`, which no current
490            // path produces. We keep the async dispatch wiring so a
491            // future resolve-using lens kind that needs heavy work
492            // can re-enable it cheaply, but we no longer snapshot
493            // `parse_result` or clone the full ledger directives:
494            // the fallback needs neither. Pre-#1253 those snapshots
495            // each cost an O(N) deep clone on every resolve request.
496            CodeLensResolve::METHOD => {
497                let id = req.id.clone();
498                let lens: CodeLens = match serde_json::from_value(req.params.clone()) {
499                    Ok(l) => l,
500                    Err(e) => {
501                        // Use unconditional dispatch (stateless
502                        // handler - see dispatch_async_unconditional).
503                        self.dispatch_async_unconditional(id, move || Err(e.to_string()));
504                        return true;
505                    }
506                };
507
508                self.dispatch_async_unconditional(id, move || {
509                    let resolved = handle_code_lens_resolve(lens);
510                    serde_json::to_value(resolved).map_err(|e| e.to_string())
511                });
512                true
513            }
514            // semanticTokens/full tokenizes the entire document - CPU-bound.
515            SemanticTokensFullRequest::METHOD => {
516                let id = req.id.clone();
517                let params: SemanticTokensParams = match serde_json::from_value(req.params.clone())
518                {
519                    Ok(p) => p,
520                    Err(e) => {
521                        self.dispatch_async(id, move || Err(e.to_string()));
522                        return true;
523                    }
524                };
525
526                // Snapshot data eagerly
527                let uri = &params.text_document.uri;
528                let (text, parse_result) = self.get_document_data(uri);
529                // Capture the negotiated encoding by value so the
530                // worker closure (which loses access to `self`) emits
531                // semantic tokens in the right wire encoding.
532                let encoding = self.position_encoding;
533
534                self.dispatch_async(id, move || {
535                    let response = handle_semantic_tokens(&params, &text, &parse_result, encoding);
536                    serde_json::to_value(response).map_err(|e| e.to_string())
537                });
538                true
539            }
540            _ => false,
541        }
542    }
543
544    /// Handle an LSP message.
545    fn handle_message(&mut self, msg: Message) {
546        match msg {
547            Message::Request(req) => self.handle_request(req),
548            Message::Notification(notif) => self.handle_notification(notif),
549            Message::Response(_resp) => {
550                // We don't currently send requests to the client
551            }
552        }
553    }
554
555    /// Handle an LSP request (expects response).
556    ///
557    /// Most read-only requests are dispatched to a background thread to keep
558    /// the main loop responsive. Requests that mutate state (initialize,
559    /// shutdown) or need ordering guarantees run synchronously.
560    fn handle_request(&mut self, req: lsp_server::Request) {
561        let id = req.id.clone();
562
563        // Check for async-dispatchable requests first.
564        // These are read-only and can safely run off the main thread.
565        if self.try_dispatch_async(&req) {
566            return; // Response will come back as Event::Task
567        }
568
569        // Send response, routed through the typed `DispatchError`
570        // (see the enum's rustdoc for the rationale - round-20
571        // replaces the prior error-message-prefix routing).
572        let response = match self.dispatch_sync(req) {
573            Ok(value) => lsp_server::Response::new_ok(id, value),
574            Err(err) => lsp_server::Response::new_err(id, err.error_code() as i32, err.to_string()),
575        };
576
577        self.send(lsp_server::Message::Response(response));
578    }
579
580    /// Synchronous request dispatch. Returns the handler's JSON
581    /// response, or a typed [`DispatchError`].
582    ///
583    /// Each match arm either handles the request inline (Shutdown,
584    /// Initialize) or delegates to a per-method handler that returns
585    /// `Result<Value, String>`; the inner `String` is wrapped via
586    /// `DispatchError::Handler` on the way out, keeping the handler
587    /// signatures unchanged.
588    fn dispatch_sync(
589        &mut self,
590        req: lsp_server::Request,
591    ) -> Result<serde_json::Value, DispatchError> {
592        // Initialize is special: a second `initialize` reaching this
593        // dispatcher is a protocol violation per LSP 3.17 §Lifecycle.
594        // Parse the params to surface a malformed-payload error if
595        // present, then emit the structured `DuplicateInitialize`.
596        if req.method == Initialize::METHOD {
597            let _params: InitializeParams = serde_json::from_value(req.params)
598                .map_err(|e| DispatchError::Handler(e.to_string()))?;
599            return Err(DispatchError::DuplicateInitialize);
600        }
601
602        let method = req.method.clone();
603        let inner: Result<serde_json::Value, String> = match method.as_str() {
604            Shutdown::METHOD => {
605                self.shutdown_requested = true;
606                Ok(serde_json::Value::Null)
607            }
608            Completion::METHOD => self.handle_completion_request(req),
609            GotoDefinition::METHOD => self.handle_goto_definition_request(req),
610            References::METHOD => self.handle_references_request(req),
611            HoverRequest::METHOD => self.handle_hover_request(req),
612            DocumentSymbolRequest::METHOD => self.handle_document_symbols_request(req),
613            SemanticTokensFullDeltaRequest::METHOD => {
614                self.handle_semantic_tokens_delta_request(req)
615            }
616            SemanticTokensRangeRequest::METHOD => self.handle_semantic_tokens_range_request(req),
617            CodeActionRequest::METHOD => self.handle_code_action_request(req),
618            CodeActionResolveRequest::METHOD => self.handle_code_action_resolve_request(req),
619            WorkspaceSymbolRequest::METHOD => self.handle_workspace_symbol_request(req),
620            PrepareRenameRequest::METHOD => self.handle_prepare_rename_request(req),
621            Rename::METHOD => self.handle_rename_request(req),
622            Formatting::METHOD => self.handle_formatting_request(req),
623            RangeFormatting::METHOD => self.handle_range_formatting_request(req),
624            DocumentLinkRequest::METHOD => self.handle_document_link_request(req),
625            DocumentLinkResolve::METHOD => self.handle_document_link_resolve_request(req),
626            InlayHintRequest::METHOD => self.handle_inlay_hint_request(req),
627            InlayHintResolveRequest::METHOD => self.handle_inlay_hint_resolve_request(req),
628            SelectionRangeRequest::METHOD => self.handle_selection_range_request(req),
629            FoldingRangeRequest::METHOD => self.handle_folding_range_request(req),
630            TypeHierarchyPrepare::METHOD => self.handle_prepare_type_hierarchy_request(req),
631            TypeHierarchySupertypes::METHOD => self.handle_type_hierarchy_supertypes_request(req),
632            TypeHierarchySubtypes::METHOD => self.handle_type_hierarchy_subtypes_request(req),
633            DocumentHighlightRequest::METHOD => self.handle_document_highlight_request(req),
634            LinkedEditingRange::METHOD => self.handle_linked_editing_range_request(req),
635            OnTypeFormatting::METHOD => self.handle_on_type_formatting_request(req),
636            CodeLensRequest::METHOD => self.handle_code_lens_request(req),
637            DocumentColor::METHOD => self.handle_document_color_request(req),
638            ColorPresentationRequest::METHOD => self.handle_color_presentation_request(req),
639            GotoDeclaration::METHOD => self.handle_goto_declaration_request(req),
640            CallHierarchyPrepare::METHOD => self.handle_prepare_call_hierarchy_request(req),
641            CallHierarchyIncomingCalls::METHOD => self.handle_incoming_calls_request(req),
642            CallHierarchyOutgoingCalls::METHOD => self.handle_outgoing_calls_request(req),
643            SignatureHelpRequest::METHOD => self.handle_signature_help_request(req),
644            ExecuteCommand::METHOD => self.handle_execute_command_request(req),
645            ResolveCompletionItem::METHOD => self.handle_completion_resolve_request(req),
646            _ => {
647                tracing::warn!("Unhandled request: {method}");
648                return Err(DispatchError::MethodNotFound(method));
649            }
650        };
651        inner.map_err(DispatchError::Handler)
652    }
653
654    /// Handle the textDocument/completion request.
655    fn handle_completion_request(
656        &self,
657        req: lsp_server::Request,
658    ) -> Result<serde_json::Value, String> {
659        let params: CompletionParams =
660            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
661
662        let uri = &params.text_document_position.text_document.uri;
663        let (text, parse_result) = self.get_document_data(uri);
664
665        // Get ledger state for multi-file completions
666        let ledger_guard = self.ledger_state.read();
667        let ledger_state = if ledger_guard.ledger().is_some() {
668            Some(&*ledger_guard)
669        } else {
670            None
671        };
672
673        let response = handle_completion(
674            &params,
675            &text,
676            &parse_result,
677            ledger_state,
678            self.position_encoding,
679        );
680
681        serde_json::to_value(response).map_err(|e| e.to_string())
682    }
683
684    /// Handle the textDocument/definition request.
685    fn handle_goto_definition_request(
686        &self,
687        req: lsp_server::Request,
688    ) -> Result<serde_json::Value, String> {
689        let params: GotoDefinitionParams =
690            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
691
692        let uri = &params.text_document_position_params.text_document.uri;
693        let (text, parse_result) = self.get_document_data(uri);
694
695        // Ledger state for cross-file (`include`d) account definitions.
696        let ledger_guard = self.ledger_state.read();
697        let ledger_state = if ledger_guard.ledger().is_some() {
698            Some(&*ledger_guard)
699        } else {
700            None
701        };
702
703        let response = handle_goto_definition(
704            &params,
705            &text,
706            &parse_result,
707            ledger_state,
708            uri,
709            self.position_encoding,
710        );
711
712        serde_json::to_value(response).map_err(|e| e.to_string())
713    }
714
715    /// Handle the textDocument/references request.
716    fn handle_references_request(
717        &self,
718        req: lsp_server::Request,
719    ) -> Result<serde_json::Value, String> {
720        let params: ReferenceParams =
721            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
722
723        let uri = &params.text_document_position.text_document.uri;
724        let (text, parse_result) = self.get_document_data(uri);
725
726        // Other ledger files so "find references" spans every `include`d file,
727        // not just the open buffer. Collected under the locks (live content for
728        // open buffers); the handler then parses lock-free.
729        let current_canonical = uri_to_path(uri).and_then(|p| p.canonicalize().ok());
730        let other_files = self.other_ledger_files(current_canonical.as_deref());
731
732        let response = handle_references(
733            &params,
734            &text,
735            &parse_result,
736            &other_files,
737            uri,
738            self.position_encoding,
739        );
740
741        serde_json::to_value(response).map_err(|e| e.to_string())
742    }
743
744    /// Handle the textDocument/hover request.
745    fn handle_hover_request(&self, req: lsp_server::Request) -> Result<serde_json::Value, String> {
746        let params: HoverParams = serde_json::from_value(req.params).map_err(|e| e.to_string())?;
747
748        let uri = &params.text_document_position_params.text_document.uri;
749        let (text, parse_result) = self.get_document_data(uri);
750
751        // Ledger state for cross-file (`include`d) account resolution.
752        let ledger_guard = self.ledger_state.read();
753        let ledger_state = if ledger_guard.ledger().is_some() {
754            Some(&*ledger_guard)
755        } else {
756            None
757        };
758
759        let response = handle_hover(
760            &params,
761            &text,
762            &parse_result,
763            ledger_state,
764            self.position_encoding,
765        );
766
767        serde_json::to_value(response).map_err(|e| e.to_string())
768    }
769
770    /// Handle the textDocument/documentSymbol request.
771    fn handle_document_symbols_request(
772        &self,
773        req: lsp_server::Request,
774    ) -> Result<serde_json::Value, String> {
775        let params: DocumentSymbolParams =
776            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
777
778        let uri = &params.text_document.uri;
779        let (text, parse_result) = self.get_document_data(uri);
780
781        let response =
782            handle_document_symbols(&params, &text, &parse_result, self.position_encoding);
783
784        serde_json::to_value(response).map_err(|e| e.to_string())
785    }
786
787    /// Handle the textDocument/semanticTokens/full/delta request.
788    fn handle_semantic_tokens_delta_request(
789        &self,
790        req: lsp_server::Request,
791    ) -> Result<serde_json::Value, String> {
792        let params: SemanticTokensDeltaParams =
793            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
794
795        let uri = &params.text_document.uri;
796        let (text, parse_result) = self.get_document_data(uri);
797
798        // Note: For a full implementation, we would store previous tokens by result_id
799        // and pass them to handle_semantic_tokens_delta. For now, pass None to always
800        // return full tokens as a delta.
801        let response = handle_semantic_tokens_delta(
802            &params,
803            &text,
804            &parse_result,
805            None,
806            self.position_encoding,
807        );
808
809        serde_json::to_value(response).map_err(|e| e.to_string())
810    }
811
812    /// Handle the textDocument/semanticTokens/range request.
813    fn handle_semantic_tokens_range_request(
814        &self,
815        req: lsp_server::Request,
816    ) -> Result<serde_json::Value, String> {
817        let params: SemanticTokensRangeParams =
818            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
819
820        let uri = &params.text_document.uri;
821        let (text, parse_result) = self.get_document_data(uri);
822
823        let response =
824            handle_semantic_tokens_range(&params, &text, &parse_result, self.position_encoding);
825
826        serde_json::to_value(response).map_err(|e| e.to_string())
827    }
828
829    /// Handle the textDocument/codeAction request.
830    fn handle_code_action_request(
831        &self,
832        req: lsp_server::Request,
833    ) -> Result<serde_json::Value, String> {
834        let params: CodeActionParams =
835            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
836
837        let uri = &params.text_document.uri;
838        let (text, parse_result) = self.get_document_data(uri);
839
840        let response = handle_code_actions(&params, &text, &parse_result, self.position_encoding);
841
842        serde_json::to_value(response).map_err(|e| e.to_string())
843    }
844
845    /// Handle the codeAction/resolve request.
846    fn handle_code_action_resolve_request(
847        &self,
848        req: lsp_server::Request,
849    ) -> Result<serde_json::Value, String> {
850        let action: CodeAction = serde_json::from_value(req.params).map_err(|e| e.to_string())?;
851
852        // Get the document URI from the action's data
853        let uri: Uri = if let Some(data) = &action.data {
854            data.get("uri")
855                .and_then(|v| v.as_str())
856                .and_then(|s| s.parse().ok())
857                .unwrap_or_else(|| "file:///unknown".parse().unwrap())
858        } else {
859            "file:///unknown".parse().unwrap()
860        };
861
862        let (text, parse_result) = self.get_document_data(&uri);
863
864        let resolved =
865            handle_code_action_resolve(action, &text, &parse_result, &uri, self.position_encoding);
866
867        serde_json::to_value(resolved).map_err(|e| e.to_string())
868    }
869
870    /// Handle the workspace/symbol request.
871    fn handle_workspace_symbol_request(
872        &self,
873        req: lsp_server::Request,
874    ) -> Result<serde_json::Value, String> {
875        let params: WorkspaceSymbolParams =
876            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
877
878        // Collect all open documents with cached parse results.
879        let mut documents: Vec<(Uri, String, Arc<ParseResult>)> = {
880            let mut vfs = self.vfs.write();
881            vfs.iter_with_parse()
882                .map(|(path, content, parse_result)| {
883                    let uri_str = format!("file://{}", path.display());
884                    let uri: Uri = uri_str
885                        .parse()
886                        .unwrap_or_else(|_| "file:///".parse().unwrap());
887                    (uri, content, parse_result)
888                })
889                .collect()
890        };
891
892        // Add ledger files reachable via `include` that aren't open, so
893        // workspace symbol search spans the whole project — not just the
894        // buffers the user happens to have open. Open buffers are listed first
895        // (and the handler dedups by name), so their live edits take precedence
896        // over the ledger snapshot.
897        let open_canonical: std::collections::HashSet<PathBuf> = self
898            .vfs
899            .read()
900            .paths()
901            .filter_map(|p| p.canonicalize().ok())
902            .collect();
903        // Collect the unopened ledger files' (uri, source) UNDER the lock, then
904        // drop the guard before parsing — re-parsing a large ledger while
905        // holding the state read-lock would block journal reloads.
906        let unopened: Vec<(Uri, String)> = {
907            let ledger_guard = self.ledger_state.read();
908            ledger_guard.ledger().map_or_else(Vec::new, |ledger| {
909                ledger
910                    .source_map
911                    .files()
912                    .iter()
913                    .filter(|f| {
914                        f.path
915                            .canonicalize()
916                            .ok()
917                            .is_none_or(|c| !open_canonical.contains(&c))
918                    })
919                    .filter_map(|f| {
920                        match format!("file://{}", f.path.display()).parse::<Uri>() {
921                            Ok(uri) => Some((uri, f.source.to_string())),
922                            Err(_) => {
923                                tracing::warn!(
924                                    "workspace/symbol: skipping ledger file with a non-file:// URI: {}",
925                                    f.path.display()
926                                );
927                                None
928                            }
929                        }
930                    })
931                    .collect()
932            })
933        };
934        for (uri, source) in unopened {
935            let parsed = Arc::new(parse(&source));
936            documents.push((uri, source, parsed));
937        }
938
939        let response = handle_workspace_symbols(&params, &documents, self.position_encoding);
940
941        serde_json::to_value(response).map_err(|e| e.to_string())
942    }
943
944    /// Handle the textDocument/prepareRename request.
945    fn handle_prepare_rename_request(
946        &self,
947        req: lsp_server::Request,
948    ) -> Result<serde_json::Value, String> {
949        let params: TextDocumentPositionParams =
950            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
951
952        let uri = &params.text_document.uri;
953        let (text, parse_result) = self.get_document_data(uri);
954
955        let response = handle_prepare_rename(&params, &text, &parse_result, self.position_encoding);
956
957        serde_json::to_value(response).map_err(|e| e.to_string())
958    }
959
960    /// Handle the textDocument/rename request.
961    fn handle_rename_request(&self, req: lsp_server::Request) -> Result<serde_json::Value, String> {
962        let params: RenameParams = serde_json::from_value(req.params).map_err(|e| e.to_string())?;
963
964        let uri = &params.text_document_position.text_document.uri;
965        let (text, parse_result) = self.get_document_data(uri);
966
967        // Gather the other ledger files (everything except the current buffer)
968        // so the rename spans every `include`d file — otherwise references in
969        // other files are left dangling. Collected under the locks here (with
970        // each file's live buffer content when open) so the handler parses with
971        // no locks held.
972        let current_canonical = uri_to_path(uri).and_then(|p| p.canonicalize().ok());
973        let other_files = self.other_ledger_files(current_canonical.as_deref());
974
975        let response = handle_rename(
976            &params,
977            &text,
978            &parse_result,
979            &other_files,
980            self.position_encoding,
981        );
982
983        serde_json::to_value(response).map_err(|e| e.to_string())
984    }
985
986    /// Collect the ledger's files reachable via `include`, EXCLUDING the file at
987    /// `current_canonical`, as `(uri, source)`. The source is the open buffer's
988    /// live content when the file is open (so edit ranges match the client),
989    /// else the loader's on-disk source. Locks are released before returning, so
990    /// callers can parse without holding them.
991    fn other_ledger_files(
992        &self,
993        current_canonical: Option<&std::path::Path>,
994    ) -> Vec<(Uri, String)> {
995        // Live content of every open buffer, keyed by canonical path.
996        let open: std::collections::HashMap<PathBuf, String> = {
997            let vfs = self.vfs.read();
998            vfs.paths()
999                .filter_map(|p| Some((p.canonicalize().ok()?, vfs.get_content(p)?)))
1000                .collect()
1001        };
1002
1003        let ledger_guard = self.ledger_state.read();
1004        let mut out = Vec::new();
1005        if let Some(ledger) = ledger_guard.ledger() {
1006            for f in ledger.source_map.files() {
1007                let canon = f.path.canonicalize().ok();
1008                if canon.as_deref() == current_canonical {
1009                    continue;
1010                }
1011                let Ok(uri) = format!("file://{}", f.path.display()).parse::<Uri>() else {
1012                    tracing::warn!(
1013                        "rename: skipping ledger file with a non-file:// URI: {}",
1014                        f.path.display()
1015                    );
1016                    continue;
1017                };
1018                // Prefer the open buffer's live content over the on-disk source
1019                // so cross-file edit ranges line up with the client's buffer.
1020                let source = canon
1021                    .as_ref()
1022                    .and_then(|c| open.get(c).cloned())
1023                    .unwrap_or_else(|| f.source.to_string());
1024                out.push((uri, source));
1025            }
1026        }
1027        out
1028    }
1029
1030    /// Handle the textDocument/formatting request.
1031    fn handle_formatting_request(
1032        &self,
1033        req: lsp_server::Request,
1034    ) -> Result<serde_json::Value, String> {
1035        let params: DocumentFormattingParams =
1036            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1037
1038        let uri = &params.text_document.uri;
1039        let (text, parse_result) = self.get_document_data(uri);
1040
1041        let response = handle_formatting(&params, &text, &parse_result, self.position_encoding);
1042
1043        serde_json::to_value(response).map_err(|e| e.to_string())
1044    }
1045
1046    /// Handle the textDocument/foldingRange request.
1047    fn handle_folding_range_request(
1048        &self,
1049        req: lsp_server::Request,
1050    ) -> Result<serde_json::Value, String> {
1051        let params: FoldingRangeParams =
1052            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1053
1054        let uri = &params.text_document.uri;
1055        let (text, parse_result) = self.get_document_data(uri);
1056
1057        let response = handle_folding_ranges(&params, &text, &parse_result, self.position_encoding);
1058
1059        serde_json::to_value(response).map_err(|e| e.to_string())
1060    }
1061
1062    /// Handle the textDocument/rangeFormatting request.
1063    fn handle_range_formatting_request(
1064        &self,
1065        req: lsp_server::Request,
1066    ) -> Result<serde_json::Value, String> {
1067        let params: DocumentRangeFormattingParams =
1068            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1069
1070        let uri = &params.text_document.uri;
1071        let (text, parse_result) = self.get_document_data(uri);
1072
1073        let response =
1074            handle_range_formatting(&params, &text, &parse_result, self.position_encoding);
1075
1076        serde_json::to_value(response).map_err(|e| e.to_string())
1077    }
1078
1079    /// Handle the textDocument/documentLink request.
1080    fn handle_document_link_request(
1081        &self,
1082        req: lsp_server::Request,
1083    ) -> Result<serde_json::Value, String> {
1084        let params: DocumentLinkParams =
1085            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1086
1087        let uri = &params.text_document.uri;
1088        let (text, parse_result) = self.get_document_data(uri);
1089
1090        let response = handle_document_links(&params, &text, &parse_result, self.position_encoding);
1091
1092        serde_json::to_value(response).map_err(|e| e.to_string())
1093    }
1094
1095    /// Handle the documentLink/resolve request.
1096    fn handle_document_link_resolve_request(
1097        &self,
1098        req: lsp_server::Request,
1099    ) -> Result<serde_json::Value, String> {
1100        let link: DocumentLink = serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1101
1102        let resolved = handle_document_link_resolve(link);
1103
1104        serde_json::to_value(resolved).map_err(|e| e.to_string())
1105    }
1106
1107    /// Handle the textDocument/inlayHint request.
1108    fn handle_inlay_hint_request(
1109        &self,
1110        req: lsp_server::Request,
1111    ) -> Result<serde_json::Value, String> {
1112        let params: InlayHintParams =
1113            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1114
1115        let uri = &params.text_document.uri;
1116        let (text, parse_result) = self.get_document_data(uri);
1117
1118        let response = handle_inlay_hints(&params, &text, &parse_result, self.position_encoding);
1119
1120        serde_json::to_value(response).map_err(|e| e.to_string())
1121    }
1122
1123    /// Handle the inlayHint/resolve request.
1124    fn handle_inlay_hint_resolve_request(
1125        &self,
1126        req: lsp_server::Request,
1127    ) -> Result<serde_json::Value, String> {
1128        let hint: InlayHint = serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1129
1130        // Get the document URI from the hint's data field
1131        let uri: Uri = if let Some(data) = &hint.data {
1132            data.get("uri")
1133                .and_then(|v| v.as_str())
1134                .and_then(|s| s.parse().ok())
1135                .unwrap_or_else(|| "file:///unknown".parse().unwrap())
1136        } else {
1137            "file:///unknown".parse().unwrap()
1138        };
1139
1140        let (_text, parse_result) = self.get_document_data(&uri);
1141        // The tooltip prefers the loaded whole-ledger pipeline output
1142        // (booked + pad-expanded) over the raw single-file parse.
1143        let ledger_guard = self.ledger_state.read();
1144        let resolved = handle_inlay_hint_resolve(hint, &parse_result, ledger_guard.ledger());
1145
1146        serde_json::to_value(resolved).map_err(|e| e.to_string())
1147    }
1148
1149    /// Handle the textDocument/selectionRange request.
1150    fn handle_selection_range_request(
1151        &self,
1152        req: lsp_server::Request,
1153    ) -> Result<serde_json::Value, String> {
1154        let params: SelectionRangeParams =
1155            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1156
1157        let uri = &params.text_document.uri;
1158        let (text, parse_result) = self.get_document_data(uri);
1159
1160        // CST handle comes from the cached ParseResult via
1161        // `parse_result.syntax_root`; no per-request re-parse.
1162        let response =
1163            handle_selection_range(&params, &text, &parse_result, self.position_encoding);
1164
1165        serde_json::to_value(response).map_err(|e| e.to_string())
1166    }
1167
1168    /// Handle the textDocument/prepareTypeHierarchy request.
1169    fn handle_prepare_type_hierarchy_request(
1170        &self,
1171        req: lsp_server::Request,
1172    ) -> Result<serde_json::Value, String> {
1173        let params: TypeHierarchyPrepareParams =
1174            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1175
1176        let uri = &params.text_document_position_params.text_document.uri;
1177        let (text, parse_result) = self.get_document_data(uri);
1178
1179        let response = handle_prepare_type_hierarchy(
1180            &params,
1181            &text,
1182            &parse_result,
1183            uri,
1184            self.position_encoding,
1185        );
1186
1187        serde_json::to_value(response).map_err(|e| e.to_string())
1188    }
1189
1190    /// Handle the typeHierarchy/supertypes request.
1191    fn handle_type_hierarchy_supertypes_request(
1192        &self,
1193        req: lsp_server::Request,
1194    ) -> Result<serde_json::Value, String> {
1195        let params: TypeHierarchySupertypesParams =
1196            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1197
1198        let uri = &params.item.uri;
1199        let (text, parse_result) = self.get_document_data(uri);
1200
1201        let response =
1202            handle_supertypes(&params, &text, &parse_result, uri, self.position_encoding);
1203
1204        serde_json::to_value(response).map_err(|e| e.to_string())
1205    }
1206
1207    /// Handle the typeHierarchy/subtypes request.
1208    fn handle_type_hierarchy_subtypes_request(
1209        &self,
1210        req: lsp_server::Request,
1211    ) -> Result<serde_json::Value, String> {
1212        let params: TypeHierarchySubtypesParams =
1213            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1214
1215        let uri = &params.item.uri;
1216        let (text, parse_result) = self.get_document_data(uri);
1217
1218        let response = handle_subtypes(&params, &text, &parse_result, uri, self.position_encoding);
1219
1220        serde_json::to_value(response).map_err(|e| e.to_string())
1221    }
1222
1223    /// Handle the textDocument/documentHighlight request.
1224    fn handle_document_highlight_request(
1225        &self,
1226        req: lsp_server::Request,
1227    ) -> Result<serde_json::Value, String> {
1228        let params: DocumentHighlightParams =
1229            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1230
1231        let uri = &params.text_document_position_params.text_document.uri;
1232        let (text, parse_result) = self.get_document_data(uri);
1233
1234        let response =
1235            handle_document_highlight(&params, &text, &parse_result, self.position_encoding);
1236
1237        serde_json::to_value(response).map_err(|e| e.to_string())
1238    }
1239
1240    /// Handle the textDocument/linkedEditingRange request.
1241    fn handle_linked_editing_range_request(
1242        &self,
1243        req: lsp_server::Request,
1244    ) -> Result<serde_json::Value, String> {
1245        let params: LinkedEditingRangeParams =
1246            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1247
1248        let uri = &params.text_document_position_params.text_document.uri;
1249        let (text, parse_result) = self.get_document_data(uri);
1250
1251        let response =
1252            handle_linked_editing_range(&params, &text, &parse_result, self.position_encoding);
1253
1254        serde_json::to_value(response).map_err(|e| e.to_string())
1255    }
1256
1257    /// Handle the textDocument/onTypeFormatting request.
1258    fn handle_on_type_formatting_request(
1259        &self,
1260        req: lsp_server::Request,
1261    ) -> Result<serde_json::Value, String> {
1262        let params: DocumentOnTypeFormattingParams =
1263            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1264
1265        let uri = &params.text_document_position.text_document.uri;
1266
1267        // Get document content from VFS (on-type formatting doesn't need parse result)
1268        let text = if let Some(path) = uri_to_path(uri) {
1269            self.vfs.read().get_content(&path).unwrap_or_default()
1270        } else {
1271            String::new()
1272        };
1273
1274        let response = handle_on_type_formatting(&params, &text, self.position_encoding);
1275
1276        serde_json::to_value(response).map_err(|e| e.to_string())
1277    }
1278
1279    /// Handle the textDocument/codeLens request.
1280    fn handle_code_lens_request(
1281        &self,
1282        req: lsp_server::Request,
1283    ) -> Result<serde_json::Value, String> {
1284        let params: CodeLensParams =
1285            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1286
1287        let uri = &params.text_document.uri;
1288        let (text, parse_result) = self.get_document_data(uri);
1289
1290        // The balance lens reads the validator's last-computed verdict
1291        // for this URI from `self.diagnostics` (#1264). Pre-#1264 we
1292        // snapshotted `ledger_state` so the lens could run its own
1293        // evaluator; that evaluator dropped plugins (effective_date,
1294        // lazy_balance, ...) and silently disagreed with `rledger check`
1295        // on every ledger that used them. The new lens consults the
1296        // diagnostic cache instead - diagnostics ARE the validator's
1297        // verdict after the full pipeline. None means cold start
1298        // (no `publish_diagnostics` for this URI yet); the lens renders
1299        // a neutral title and never claims a verdict it can't back up.
1300        let cached_diagnostics = self.diagnostics.get(uri).map(Vec::as_slice);
1301
1302        let response = handle_code_lens(
1303            &params,
1304            &text,
1305            &parse_result,
1306            cached_diagnostics,
1307            self.position_encoding,
1308        );
1309
1310        serde_json::to_value(response).map_err(|e| e.to_string())
1311    }
1312
1313    /// Handle the textDocument/documentColor request.
1314    fn handle_document_color_request(
1315        &self,
1316        req: lsp_server::Request,
1317    ) -> Result<serde_json::Value, String> {
1318        let params: DocumentColorParams =
1319            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1320
1321        let uri = &params.text_document.uri;
1322        let (text, parse_result) = self.get_document_data(uri);
1323
1324        let response = handle_document_color(&params, &text, &parse_result, self.position_encoding);
1325
1326        serde_json::to_value(response).map_err(|e| e.to_string())
1327    }
1328
1329    /// Handle the textDocument/colorPresentation request.
1330    fn handle_color_presentation_request(
1331        &self,
1332        req: lsp_server::Request,
1333    ) -> Result<serde_json::Value, String> {
1334        let params: ColorPresentationParams =
1335            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1336
1337        // Handle color presentation
1338        let response = handle_color_presentation(&params);
1339
1340        serde_json::to_value(response).map_err(|e| e.to_string())
1341    }
1342
1343    /// Handle the textDocument/declaration request.
1344    fn handle_goto_declaration_request(
1345        &self,
1346        req: lsp_server::Request,
1347    ) -> Result<serde_json::Value, String> {
1348        let params: GotoDefinitionParams =
1349            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1350
1351        let uri = &params.text_document_position_params.text_document.uri;
1352        let (text, parse_result) = self.get_document_data(uri);
1353
1354        // Ledger state for cross-file (`include`d) account declarations.
1355        let ledger_guard = self.ledger_state.read();
1356        let ledger_state = if ledger_guard.ledger().is_some() {
1357            Some(&*ledger_guard)
1358        } else {
1359            None
1360        };
1361
1362        // Handle go-to-declaration (same as definition for Beancount)
1363        let response = handle_goto_declaration(
1364            &params,
1365            &text,
1366            &parse_result,
1367            ledger_state,
1368            uri,
1369            self.position_encoding,
1370        );
1371
1372        serde_json::to_value(response).map_err(|e| e.to_string())
1373    }
1374
1375    /// Handle the textDocument/prepareCallHierarchy request.
1376    fn handle_prepare_call_hierarchy_request(
1377        &self,
1378        req: lsp_server::Request,
1379    ) -> Result<serde_json::Value, String> {
1380        let params: CallHierarchyPrepareParams =
1381            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1382
1383        let uri = &params.text_document_position_params.text_document.uri;
1384        let (text, parse_result) = self.get_document_data(uri);
1385
1386        let response = handle_prepare_call_hierarchy(
1387            &params,
1388            &text,
1389            &parse_result,
1390            uri,
1391            self.position_encoding,
1392        );
1393
1394        serde_json::to_value(response).map_err(|e| e.to_string())
1395    }
1396
1397    /// Handle the callHierarchy/incomingCalls request.
1398    fn handle_incoming_calls_request(
1399        &self,
1400        req: lsp_server::Request,
1401    ) -> Result<serde_json::Value, String> {
1402        let params: CallHierarchyIncomingCallsParams =
1403            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1404
1405        let uri = &params.item.uri;
1406        let (text, parse_result) = self.get_document_data(uri);
1407
1408        let response =
1409            handle_incoming_calls(&params, &text, &parse_result, uri, self.position_encoding);
1410
1411        serde_json::to_value(response).map_err(|e| e.to_string())
1412    }
1413
1414    /// Handle the callHierarchy/outgoingCalls request.
1415    fn handle_outgoing_calls_request(
1416        &self,
1417        req: lsp_server::Request,
1418    ) -> Result<serde_json::Value, String> {
1419        let params: CallHierarchyOutgoingCallsParams =
1420            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1421
1422        let uri = &params.item.uri;
1423        let (text, parse_result) = self.get_document_data(uri);
1424
1425        let response =
1426            handle_outgoing_calls(&params, &text, &parse_result, uri, self.position_encoding);
1427
1428        serde_json::to_value(response).map_err(|e| e.to_string())
1429    }
1430
1431    /// Handle the textDocument/signatureHelp request.
1432    fn handle_signature_help_request(
1433        &self,
1434        req: lsp_server::Request,
1435    ) -> Result<serde_json::Value, String> {
1436        let params: SignatureHelpParams =
1437            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1438
1439        let uri = &params.text_document_position_params.text_document.uri;
1440
1441        // Get document content from VFS
1442        let text = if let Some(path) = uri_to_path(uri) {
1443            self.vfs.read().get_content(&path).unwrap_or_default()
1444        } else {
1445            String::new()
1446        };
1447
1448        // Handle signature help (doesn't need parse result)
1449        let response = handle_signature_help(&params, &text, self.position_encoding);
1450
1451        serde_json::to_value(response).map_err(|e| e.to_string())
1452    }
1453
1454    /// Handle the workspace/executeCommand request.
1455    fn handle_execute_command_request(
1456        &self,
1457        req: lsp_server::Request,
1458    ) -> Result<serde_json::Value, String> {
1459        let params: ExecuteCommandParams =
1460            serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1461
1462        // Try to get URI from command arguments first
1463        let uri_from_args: Option<Uri> = params
1464            .arguments
1465            .first()
1466            .and_then(|arg| arg.get("uri"))
1467            .and_then(|v| v.as_str())
1468            .and_then(|s| s.parse().ok());
1469
1470        if let Some(uri) = uri_from_args {
1471            let (text, parse_result) = self.get_document_data(&uri);
1472            let result =
1473                handle_execute_command(&params, &text, &parse_result, &uri, self.position_encoding);
1474            self.send_show_message(result.show_message);
1475            return Ok(result.response.unwrap_or(serde_json::Value::Null));
1476        }
1477
1478        // Fall back to first open document (legacy behavior)
1479        let first_path = self.vfs.read().paths().next().cloned();
1480        let path = match first_path {
1481            Some(p) => p,
1482            None => {
1483                return Ok(serde_json::json!({
1484                    "error": "No document open"
1485                }));
1486            }
1487        };
1488
1489        // Convert path to URI
1490        #[cfg(not(windows))]
1491        let uri: Uri = format!("file://{}", path.display())
1492            .parse()
1493            .map_err(|e| format!("{:?}", e))?;
1494        #[cfg(windows)]
1495        let uri: Uri = format!("file:///{}", path.display())
1496            .parse()
1497            .map_err(|e| format!("{:?}", e))?;
1498
1499        let (text, parse_result) = self.get_document_data(&uri);
1500        let result =
1501            handle_execute_command(&params, &text, &parse_result, &uri, self.position_encoding);
1502        self.send_show_message(result.show_message);
1503        Ok(result.response.unwrap_or(serde_json::Value::Null))
1504    }
1505
1506    /// Send a `window/showMessage` notification, if any.
1507    fn send_show_message(&self, params: Option<lsp_types::ShowMessageParams>) {
1508        let Some(params) = params else { return };
1509        let notif = lsp_server::Notification::new(
1510            <lsp_types::notification::ShowMessage as lsp_types::notification::Notification>::METHOD
1511                .to_string(),
1512            params,
1513        );
1514        self.send(lsp_server::Message::Notification(notif));
1515    }
1516
1517    /// Handle the completionItem/resolve request.
1518    fn handle_completion_resolve_request(
1519        &self,
1520        req: lsp_server::Request,
1521    ) -> Result<serde_json::Value, String> {
1522        let item: CompletionItem = serde_json::from_value(req.params).map_err(|e| e.to_string())?;
1523
1524        // Try to get URI from the completion item's data field
1525        let uri: Uri = if let Some(data) = &item.data {
1526            data.get("uri")
1527                .and_then(|v| v.as_str())
1528                .and_then(|s| s.parse().ok())
1529                .unwrap_or_else(|| "file:///unknown".parse().unwrap())
1530        } else {
1531            "file:///unknown".parse().unwrap()
1532        };
1533
1534        let (_text, parse_result) = self.get_document_data(&uri);
1535
1536        // Resolve the detail (balances, transaction counts, price
1537        // history) against the full ledger when a journalFile is
1538        // configured — the loaded ledger spans every included file, so
1539        // the popup reflects whole-ledger totals instead of just the
1540        // file the cursor is in. Falls back to the current file's
1541        // directives when no ledger is loaded. Mirrors how
1542        // `handle_completion_request` consults `ledger_state`, and
1543        // keeps the detail consistent with `hover` (issue #1297).
1544        let ledger_guard = self.ledger_state.read();
1545        let directives = ledger_guard
1546            .directives()
1547            .unwrap_or_else(|| parse_result.directives.as_slice());
1548        let resolved = handle_completion_resolve(item, directives);
1549
1550        serde_json::to_value(resolved).map_err(|e| e.to_string())
1551    }
1552
1553    /// Handle an LSP notification (no response expected).
1554    fn handle_notification(&mut self, notif: lsp_server::Notification) {
1555        // Notifications are handled synchronously - this is critical for correctness
1556        match notif.method.as_str() {
1557            DidOpenTextDocument::METHOD => {
1558                if let Ok(params) =
1559                    serde_json::from_value::<lsp_types::DidOpenTextDocumentParams>(notif.params)
1560                {
1561                    self.on_did_open(params);
1562                }
1563            }
1564            DidChangeTextDocument::METHOD => {
1565                if let Ok(params) =
1566                    serde_json::from_value::<lsp_types::DidChangeTextDocumentParams>(notif.params)
1567                {
1568                    self.on_did_change(params);
1569                }
1570            }
1571            DidCloseTextDocument::METHOD => {
1572                if let Ok(params) =
1573                    serde_json::from_value::<lsp_types::DidCloseTextDocumentParams>(notif.params)
1574                {
1575                    self.on_did_close(params);
1576                }
1577            }
1578            DidChangeWatchedFiles::METHOD => {
1579                if let Ok(params) =
1580                    serde_json::from_value::<lsp_types::DidChangeWatchedFilesParams>(notif.params)
1581                {
1582                    self.on_did_change_watched_files(params);
1583                }
1584            }
1585            "initialized" => {
1586                tracing::info!("Client initialized");
1587                // Register for file watching after initialization
1588                self.register_file_watchers();
1589            }
1590            "exit" => {
1591                tracing::info!("Exit notification received");
1592                let code = if self.shutdown_requested { 0 } else { 1 };
1593                // Signal the main loop to break with this code; the
1594                // caller will drain the writer thread before exiting.
1595                // See `pending_exit_code` field rustdoc.
1596                self.pending_exit_code = Some(code);
1597                // Invoke any caller-supplied side effect (test harnesses
1598                // pass a no-op; production passes a no-op too post-fix
1599                // because the actual process::exit is now done by the
1600                // outer caller AFTER io_threads.join()).
1601                if let Some(action) = self.exit_action.take() {
1602                    action(code);
1603                }
1604            }
1605            _ => {
1606                tracing::debug!("Unhandled notification: {}", notif.method);
1607            }
1608        }
1609    }
1610
1611    /// Handle textDocument/didOpen notification.
1612    fn on_did_open(&mut self, params: lsp_types::DidOpenTextDocumentParams) {
1613        let uri = params.text_document.uri;
1614        let text = params.text_document.text;
1615        let version = params.text_document.version;
1616
1617        tracing::info!("Document opened: {}", uri.as_str());
1618
1619        // Store in VFS
1620        if let Some(path) = uri_to_path(&uri) {
1621            self.vfs.write().open(path, text.clone(), version);
1622        }
1623
1624        // Bump revision (invalidates any in-flight requests)
1625        self.bump_revision();
1626
1627        // Compute and publish diagnostics
1628        self.publish_diagnostics(&uri, &text);
1629    }
1630
1631    /// Handle textDocument/didChange notification.
1632    fn on_did_change(&mut self, params: lsp_types::DidChangeTextDocumentParams) {
1633        let uri = params.text_document.uri;
1634        let version = params.text_document.version;
1635
1636        // For full sync, take the last change (which is the full content)
1637        if let Some(change) = params.content_changes.into_iter().last() {
1638            let text = change.text;
1639
1640            tracing::debug!("Document changed: {}", uri.as_str());
1641
1642            // Update VFS
1643            if let Some(path) = uri_to_path(&uri) {
1644                self.vfs.write().update(&path, text.clone(), version);
1645            }
1646
1647            // Bump revision
1648            self.bump_revision();
1649
1650            // Recompute diagnostics
1651            self.publish_diagnostics(&uri, &text);
1652        }
1653    }
1654
1655    /// Handle textDocument/didClose notification.
1656    fn on_did_close(&mut self, params: lsp_types::DidCloseTextDocumentParams) {
1657        let uri = params.text_document.uri;
1658
1659        tracing::info!("Document closed: {}", uri.as_str());
1660
1661        // Remove from VFS
1662        if let Some(path) = uri_to_path(&uri) {
1663            self.vfs.write().close(&path);
1664        }
1665
1666        // Clear diagnostics
1667        self.diagnostics.remove(&uri);
1668        self.send_diagnostics(&uri, vec![]);
1669    }
1670
1671    /// Handle workspace/didChangeWatchedFiles notification.
1672    fn on_did_change_watched_files(&mut self, params: lsp_types::DidChangeWatchedFilesParams) {
1673        tracing::info!("Watched files changed: {} files", params.changes.len());
1674
1675        let mut should_reload_journal = false;
1676        let mut should_revalidate = false;
1677
1678        for change in params.changes {
1679            tracing::debug!("File {:?}: {:?}", change.uri.as_str(), change.typ);
1680
1681            // Check if the changed file is part of our journal
1682            if let Some(path) = uri_to_path(&change.uri) {
1683                let ledger_guard = self.ledger_state.read();
1684                if ledger_guard.contains_file(&path) {
1685                    should_reload_journal = true;
1686                }
1687            }
1688
1689            // If a .beancount or .bean file changed externally, mark for revalidation
1690            if change.uri.as_str().ends_with(".beancount") || change.uri.as_str().ends_with(".bean")
1691            {
1692                should_revalidate = true;
1693            }
1694        }
1695
1696        // Reload the journal if any of its files changed
1697        if should_reload_journal {
1698            tracing::info!("Reloading journal due to external file changes");
1699            self.reload_journal();
1700        }
1701
1702        // Re-validate open documents once after processing all changes
1703        if should_revalidate {
1704            self.revalidate_open_documents();
1705        }
1706    }
1707
1708    /// Re-validate all open documents (e.g., after an included file changes).
1709    fn revalidate_open_documents(&mut self) {
1710        let paths: Vec<_> = self.vfs.read().paths().cloned().collect();
1711
1712        // Collect contents first to avoid borrow issues
1713        let documents: Vec<_> = paths
1714            .into_iter()
1715            .filter_map(|path| {
1716                let content = self.vfs.read().get_content(&path)?;
1717                let uri_str = format!("file://{}", path.display());
1718                let uri = uri_str.parse::<Uri>().ok()?;
1719                Some((uri, content))
1720            })
1721            .collect();
1722
1723        // Now publish diagnostics
1724        for (uri, content) in documents {
1725            tracing::debug!("Revalidating: {}", uri.as_str());
1726            self.publish_diagnostics(&uri, &content);
1727        }
1728    }
1729
1730    /// Register file watchers with the client.
1731    fn register_file_watchers(&self) {
1732        // Create a registration request for file watching
1733        let watchers = vec![
1734            lsp_types::FileSystemWatcher {
1735                glob_pattern: lsp_types::GlobPattern::String("**/*.beancount".to_string()),
1736                kind: Some(lsp_types::WatchKind::all()),
1737            },
1738            lsp_types::FileSystemWatcher {
1739                glob_pattern: lsp_types::GlobPattern::String("**/*.bean".to_string()),
1740                kind: Some(lsp_types::WatchKind::all()),
1741            },
1742        ];
1743
1744        let registration = lsp_types::Registration {
1745            id: "file-watcher".to_string(),
1746            method: "workspace/didChangeWatchedFiles".to_string(),
1747            register_options: Some(
1748                serde_json::to_value(lsp_types::DidChangeWatchedFilesRegistrationOptions {
1749                    watchers,
1750                })
1751                .unwrap_or_default(),
1752            ),
1753        };
1754
1755        let params = lsp_types::RegistrationParams {
1756            registrations: vec![registration],
1757        };
1758
1759        // Send the registration request
1760        let request = lsp_server::Request::new(
1761            lsp_server::RequestId::from("register-file-watchers".to_string()),
1762            "client/registerCapability".to_string(),
1763            params,
1764        );
1765
1766        self.send(lsp_server::Message::Request(request));
1767        tracing::info!("Registered file watchers for *.beancount and *.bean files");
1768    }
1769
1770    /// Parse document and publish diagnostics (parse errors + validation errors).
1771    ///
1772    /// When a full ledger is loaded (multi-file mode), validation considers all
1773    /// files in the ledger, providing accurate diagnostics for balance assertions
1774    /// that depend on transactions in other files.
1775    ///
1776    /// To handle unsaved edits in multiple open buffers (#685 / #760), this
1777    /// collects fresh parses from the VFS for every open document that is
1778    /// part of the ledger and hands them to `all_diagnostics` as overlays.
1779    /// The VFS caches parses per document and invalidates on update, so
1780    /// this is usually a cache hit (O(1) per open buffer) except immediately
1781    /// after an edit to that buffer.
1782    fn publish_diagnostics(&mut self, uri: &Uri, text: &str) {
1783        // Parse the current document.
1784        let result = parse(text);
1785
1786        // Canonicalize the current URI's path so we can both skip it when
1787        // collecting "other" buffer overlays and look up its file_id in
1788        // the ledger source map.
1789        let current_canonical_path = uri_to_path(uri).and_then(|p| p.canonicalize().ok());
1790
1791        // Collect fresh parses for every OTHER open buffer via the VFS.
1792        // Done before grabbing the ledger-state read lock so the VFS
1793        // write lock (needed by the cache-aware iterator) is released
1794        // before we start the file_id lookups.
1795        //
1796        // We skip:
1797        //   - the current file (its fresh parse is already in `result`)
1798        //   - any buffer whose fresh parse has errors (keeping the stale
1799        //     ledger directives is better than overlaying a partial parse)
1800        //
1801        // Each entry returns the canonicalized path + the cached Arc of
1802        // the parse result, which owns the directives we hand into
1803        // `all_diagnostics`. The Arc keeps them alive for the call.
1804        let other_buffer_parses: Vec<(PathBuf, Arc<ParseResult>)> = {
1805            let mut vfs = self.vfs.write();
1806            vfs.iter_with_parse()
1807                .filter_map(|(path, _text, parsed)| {
1808                    let canonical = path.canonicalize().ok()?;
1809                    if Some(&canonical) == current_canonical_path.as_ref() {
1810                        return None;
1811                    }
1812                    if !parsed.errors.is_empty() {
1813                        return None;
1814                    }
1815                    Some((canonical, parsed))
1816                })
1817                .collect()
1818        };
1819
1820        // Get ledger state and the current file's file_id.
1821        let ledger_guard = self.ledger_state.read();
1822        let (ledger_state, current_file_id) = if ledger_guard.ledger().is_some() {
1823            // Find the file_id for this URI by matching against included files.
1824            // Canonicalized comparison handles path normalization (e.g.,
1825            // /a/b/../c vs /a/c, or symlinks).
1826            let file_id = current_canonical_path.as_ref().and_then(|canonical| {
1827                ledger_guard.ledger().and_then(|ledger| {
1828                    ledger.source_map.files().iter().find_map(|f| {
1829                        f.path
1830                            .canonicalize()
1831                            .ok()
1832                            .filter(|canonical_f| canonical_f == canonical)
1833                            .map(|_| f.id as u16)
1834                    })
1835                })
1836            });
1837            (Some(&*ledger_guard), file_id)
1838        } else {
1839            (None, None)
1840        };
1841
1842        // Resolve each other buffer's file_id against the ledger source
1843        // map. Buffers that aren't part of the ledger get dropped (they
1844        // can't affect validation anyway).
1845        let other_buffer_overlays: Vec<(u16, &[Spanned<Directive>])> =
1846            if let Some(ls) = ledger_state {
1847                let ledger = ls.ledger().expect("ledger_state.ledger() checked above");
1848                other_buffer_parses
1849                    .iter()
1850                    .filter_map(|(canonical, parsed)| {
1851                        let fid = ledger.source_map.files().iter().find_map(|f| {
1852                            f.path
1853                                .canonicalize()
1854                                .ok()
1855                                .filter(|canonical_f| canonical_f == canonical)
1856                                .map(|_| f.id as u16)
1857                        })?;
1858                        Some((fid, parsed.directives.as_slice()))
1859                    })
1860                    .collect()
1861            } else {
1862                Vec::new()
1863            };
1864
1865        // Convert parse errors and validation errors to LSP diagnostics
1866        let diagnostics = all_diagnostics(
1867            &result,
1868            text,
1869            ledger_state,
1870            current_file_id,
1871            current_canonical_path.as_deref(),
1872            &other_buffer_overlays,
1873            self.position_encoding,
1874        );
1875
1876        // Compute diagnostics for ledger files reachable via `include` that are
1877        // NOT open in any buffer — otherwise validation errors that live in an
1878        // unopened included file (e.g. an unbalanced transaction) never surface
1879        // anywhere. Open files publish their own diagnostics via didOpen/
1880        // didChange, so they're skipped here.
1881        let cross_file = self.compute_unopened_ledger_diagnostics(
1882            ledger_state,
1883            current_canonical_path.as_deref(),
1884            current_file_id,
1885            &result,
1886            &other_buffer_overlays,
1887        );
1888        drop(ledger_guard); // Release lock before sending
1889
1890        tracing::debug!(
1891            "Publishing {} diagnostics for {} (file_id: {:?})",
1892            diagnostics.len(),
1893            uri.as_str(),
1894            current_file_id
1895        );
1896
1897        // Cache and send
1898        self.diagnostics.insert(uri.clone(), diagnostics.clone());
1899        self.send_diagnostics(uri, diagnostics);
1900
1901        // Publish (or clear) diagnostics for unopened included files.
1902        self.publish_cross_file_diagnostics(cross_file);
1903    }
1904
1905    /// Compute diagnostics for every ledger file reachable via `include` that
1906    /// is NOT currently open in a buffer. Each unopened file is validated
1907    /// against the full ledger (reusing [`all_diagnostics`], filtered to that
1908    /// file's id and mapped against that file's own source), with the open
1909    /// buffers' fresh parses overlaid so unsaved edits are reflected.
1910    ///
1911    /// Returns one `(uri, diagnostics)` per unopened ledger file (diagnostics
1912    /// may be empty — the caller clears those it had previously reported).
1913    /// Cost is O(unopened ledger files) validations; real-world ledgers have
1914    /// few files, and this only runs when the ledger spans more than one file.
1915    fn compute_unopened_ledger_diagnostics(
1916        &self,
1917        ledger_state: Option<&crate::ledger_state::LedgerState>,
1918        current_canonical: Option<&std::path::Path>,
1919        current_file_id: Option<u16>,
1920        current_parse: &ParseResult,
1921        other_buffer_overlays: &[(u16, &[Spanned<Directive>])],
1922    ) -> Vec<(Uri, Vec<lsp_types::Diagnostic>)> {
1923        let Some(ls) = ledger_state else {
1924            return Vec::new();
1925        };
1926        let Some(ledger) = ls.ledger() else {
1927            return Vec::new();
1928        };
1929        if ledger.source_map.files().len() <= 1 {
1930            return Vec::new();
1931        }
1932
1933        // Canonical paths of all open buffers (so we skip files that
1934        // self-publish).
1935        let open_canonical: std::collections::HashSet<PathBuf> = {
1936            let vfs = self.vfs.read();
1937            vfs.paths().filter_map(|p| p.canonicalize().ok()).collect()
1938        };
1939
1940        // Overlay applied when validating an unopened file: every open buffer's
1941        // fresh parse (current file first, then the others). The current
1942        // buffer is overlaid only when its fresh parse is clean — overlaying a
1943        // partial/invalid parse would corrupt the validation input for the
1944        // other files (matching the parse-error skip applied to the `other`
1945        // buffers in `publish_diagnostics`).
1946        let mut overlay_all: Vec<(u16, &[Spanned<Directive>])> =
1947            Vec::with_capacity(1 + other_buffer_overlays.len());
1948        if let Some(fid) = current_file_id
1949            && current_parse.errors.is_empty()
1950        {
1951            overlay_all.push((fid, current_parse.directives.as_slice()));
1952        }
1953        overlay_all.extend_from_slice(other_buffer_overlays);
1954
1955        let mut out = Vec::new();
1956        for f in ledger.source_map.files() {
1957            let canonical = f.path.canonicalize().ok();
1958            // Skip the current file and any open buffer — those self-publish.
1959            if canonical.as_deref() == current_canonical {
1960                continue;
1961            }
1962            if let Some(c) = &canonical
1963                && open_canonical.contains(c)
1964            {
1965                continue;
1966            }
1967            // NOTE: this `file://{path}` assembly matches the crate-wide
1968            // convention (see `revalidate_open_documents`, `document_links`,
1969            // and `uri_to_path`, which strips a plain `file://` prefix without
1970            // percent-decoding). A path with characters that aren't URI-safe
1971            // (spaces, `#`, `%`) would fail to parse; warn rather than drop it
1972            // silently. A proper percent-encoding `path_to_uri` helper applied
1973            // consistently across the crate is the broader fix.
1974            let Ok(uri) = format!("file://{}", f.path.display()).parse::<Uri>() else {
1975                tracing::warn!(
1976                    "skipping cross-file diagnostics for {}: path is not a valid file:// URI",
1977                    f.path.display()
1978                );
1979                continue;
1980            };
1981            let parsed = parse(&f.source);
1982            let diags = all_diagnostics(
1983                &parsed,
1984                &f.source,
1985                ledger_state,
1986                Some(f.id as u16),
1987                Some(f.path.as_path()),
1988                &overlay_all,
1989                self.position_encoding,
1990            );
1991            out.push((uri, diags));
1992        }
1993        out
1994    }
1995
1996    /// Publish or clear diagnostics for unopened included files.
1997    ///
1998    /// Non-empty sets are published and their URIs tracked in
1999    /// [`MainLoopState::cross_file_diag_uris`]. Any previously-tracked URI that
2000    /// is NOT erroring this round is then explicitly cleared (empty publish) and
2001    /// untracked — this covers a fixed error, an include-graph change, the
2002    /// ledger becoming single-file/unavailable (so `cross_file` is empty), all
2003    /// of which would otherwise leave stale errors in the client. URIs of files
2004    /// currently open in a buffer are left alone: those buffers publish (and
2005    /// clear) their own diagnostics via didOpen/didChange/didClose.
2006    #[allow(clippy::mutable_key_type)] // Uri has interior mutability but is safe as a set key here
2007    fn publish_cross_file_diagnostics(
2008        &mut self,
2009        cross_file: Vec<(Uri, Vec<lsp_types::Diagnostic>)>,
2010    ) {
2011        // Publish the files that have errors this round.
2012        let mut erroring: std::collections::HashSet<Uri> = std::collections::HashSet::new();
2013        for (uri, diags) in cross_file {
2014            if diags.is_empty() {
2015                continue;
2016            }
2017            erroring.insert(uri.clone());
2018            self.cross_file_diag_uris.insert(uri.clone());
2019            self.diagnostics.insert(uri.clone(), diags.clone());
2020            self.send_diagnostics(&uri, diags);
2021        }
2022
2023        // Clear any previously-tracked URI that is no longer erroring and is not
2024        // currently open (open buffers manage their own diagnostics).
2025        let open_uris: std::collections::HashSet<Uri> = self
2026            .vfs
2027            .read()
2028            .paths()
2029            .filter_map(|p| format!("file://{}", p.display()).parse::<Uri>().ok())
2030            .collect();
2031        let stale: Vec<Uri> = self
2032            .cross_file_diag_uris
2033            .iter()
2034            .filter(|u| !erroring.contains(*u) && !open_uris.contains(*u))
2035            .cloned()
2036            .collect();
2037        for uri in stale {
2038            self.cross_file_diag_uris.remove(&uri);
2039            self.diagnostics.remove(&uri);
2040            self.send_diagnostics(&uri, vec![]);
2041        }
2042    }
2043
2044    /// Send diagnostics to the client.
2045    fn send_diagnostics(&self, uri: &Uri, diagnostics: Vec<lsp_types::Diagnostic>) {
2046        let params = PublishDiagnosticsParams {
2047            uri: uri.clone(),
2048            diagnostics,
2049            version: None,
2050        };
2051
2052        let notif = lsp_server::Notification::new(PublishDiagnostics::METHOD.to_string(), params);
2053
2054        self.send(lsp_server::Message::Notification(notif));
2055    }
2056
2057    /// Send a message to the client.
2058    fn send(&self, msg: lsp_server::Message) {
2059        if let Err(e) = self.sender.send(msg) {
2060            tracing::error!("Failed to send message: {}", e);
2061        }
2062    }
2063}
2064
2065/// Run the main event loop.
2066///
2067/// Uses `crossbeam_channel::select!` to multiplex between incoming LSP
2068/// messages and results from background task threads, keeping the main
2069/// loop responsive while expensive requests run in parallel.
2070///
2071/// # Arguments
2072///
2073/// * `receiver` - Channel to receive LSP messages from the client
2074/// * `sender` - Channel to send LSP messages to the client
2075/// * `journal_file` - Optional path to the root journal file for multi-file support
2076///
2077/// # Returns
2078///
2079/// The exit code from the `exit` notification (after the loop breaks
2080/// cleanly), or `0` if the channel was closed before an `exit`
2081/// notification arrived. The caller is expected to drain any IO
2082/// threads (e.g., `lsp_server::Connection::stdio()`'s
2083/// `io_threads.join()`) AFTER this returns and BEFORE terminating the
2084/// process - otherwise the shutdown response queued in the writer
2085/// thread's channel never reaches the client, which is the bug behind
2086/// the `stdio_smoke` CI flake.
2087#[must_use]
2088pub fn run_main_loop(
2089    receiver: Receiver<lsp_server::Message>,
2090    sender: Sender<lsp_server::Message>,
2091    journal_file: Option<PathBuf>,
2092    position_encoding: crate::handlers::utils::PositionEncoding,
2093) -> i32 {
2094    // No-op exit_action: the actual process termination (if any) is
2095    // the caller's responsibility, performed AFTER io_threads.join()
2096    // has drained the writer. The returned code is the source of
2097    // truth.
2098    run_main_loop_with_exit_action(
2099        receiver,
2100        sender,
2101        journal_file,
2102        position_encoding,
2103        |_code| {},
2104    )
2105}
2106
2107/// Same as [`run_main_loop`] but with a caller-supplied `exit_action`
2108/// invoked when the `exit` notification arrives.
2109///
2110/// Production calls [`run_main_loop`], which wires the action to a
2111/// no-op (process termination, if any, is the caller's responsibility
2112/// AFTER `io_threads.join()`). The in-process integration test harness
2113/// calls this entry point with a no-op so receipt of `exit` does NOT
2114/// terminate the cargo-test process. After the no-op returns, the
2115/// main loop continues running until the connection is closed; the
2116/// harness completes shutdown by dropping the client side of the
2117/// `Connection::memory()` pair, which closes the channel and makes
2118/// the inner `select!` return `Err`, breaking the loop cleanly.
2119///
2120/// # Example
2121///
2122/// ```ignore
2123/// use lsp_server::Connection;
2124/// use rustledger_lsp::{handlers::utils::PositionEncoding, run_main_loop_with_exit_action};
2125///
2126/// let (server, client) = Connection::memory();
2127/// std::thread::spawn(move || {
2128///     run_main_loop_with_exit_action(
2129///         server.receiver,
2130///         server.sender,
2131///         None,
2132///         PositionEncoding::Utf8,
2133///         |_code| {}, // test harness: don't terminate the process
2134///     );
2135/// });
2136/// // ... drive `client` with LSP messages ...
2137/// drop(client); // closes the channel; the server thread exits.
2138/// ```
2139#[must_use]
2140pub fn run_main_loop_with_exit_action<F>(
2141    receiver: Receiver<lsp_server::Message>,
2142    sender: Sender<lsp_server::Message>,
2143    journal_file: Option<PathBuf>,
2144    position_encoding: crate::handlers::utils::PositionEncoding,
2145    exit_action: F,
2146) -> i32
2147where
2148    F: FnOnce(i32) + Send + 'static,
2149{
2150    let mut state = MainLoopState::new(sender, journal_file).with_exit_action(exit_action);
2151    state.position_encoding = position_encoding;
2152    let task_receiver = state.task_receiver.clone();
2153
2154    tracing::info!("Main loop started");
2155
2156    let exit_code = loop {
2157        crossbeam_channel::select! {
2158            recv(receiver) -> msg => {
2159                let msg = match msg {
2160                    Ok(msg) => msg,
2161                    Err(_) => break 0, // Channel closed without an `exit` notification.
2162                };
2163                let event = match msg {
2164                    lsp_server::Message::Request(req) => Event::Message(Message::Request(req)),
2165                    lsp_server::Message::Notification(notif) => {
2166                        Event::Message(Message::Notification(notif))
2167                    }
2168                    lsp_server::Message::Response(resp) => Event::Message(Message::Response(resp)),
2169                };
2170                state.handle_event(event);
2171            }
2172            recv(task_receiver) -> task_result => {
2173                if let Ok(result) = task_result {
2174                    state.handle_event(Event::Task(result));
2175                }
2176            }
2177        }
2178        // The `exit` notification handler signals via `pending_exit_code`
2179        // instead of calling `process::exit` directly. Break here so
2180        // the caller can drain the writer thread before terminating
2181        // the process; otherwise the queued shutdown response can be
2182        // lost on slow IO. See the field's rustdoc and the
2183        // `stdio_smoke` flake discussion.
2184        if let Some(code) = state.pending_exit_code {
2185            break code;
2186        }
2187    };
2188
2189    tracing::info!("Main loop ended (exit code {exit_code})");
2190    exit_code
2191}
2192
2193#[cfg(test)]
2194mod tests {
2195    use super::*;
2196
2197    /// Pin the structural error-code mapping. Round-20 introduced
2198    /// `DispatchError` to replace the prior `msg.starts_with("...")`
2199    /// routing in `handle_request`; this test guards against silent
2200    /// regressions where a future refactor swaps a variant's mapped
2201    /// code (e.g. routing `DuplicateInitialize` to `InternalError`,
2202    /// which would mask a client-side protocol violation as a server
2203    /// fault).
2204    #[test]
2205    fn dispatch_error_codes() {
2206        // `lsp_server::ErrorCode` doesn't implement PartialEq, so
2207        // compare via the wire integer (which is what we serialize).
2208        assert_eq!(
2209            DispatchError::MethodNotFound("foo/bar".into()).error_code() as i32,
2210            lsp_server::ErrorCode::MethodNotFound as i32,
2211        );
2212        assert_eq!(
2213            DispatchError::DuplicateInitialize.error_code() as i32,
2214            lsp_server::ErrorCode::InvalidRequest as i32,
2215        );
2216        assert_eq!(
2217            DispatchError::Handler("boom".into()).error_code() as i32,
2218            lsp_server::ErrorCode::InternalError as i32,
2219        );
2220    }
2221
2222    /// `Display` produces stable, distinguishable messages - clients
2223    /// (and humans tailing logs) shouldn't see the same string for
2224    /// two different failure modes.
2225    #[test]
2226    fn dispatch_error_display() {
2227        let unhandled = DispatchError::MethodNotFound("foo/bar".into()).to_string();
2228        assert!(
2229            unhandled.contains("foo/bar"),
2230            "MethodNotFound should include the method name: {unhandled}",
2231        );
2232
2233        let dup_init = DispatchError::DuplicateInitialize.to_string();
2234        assert!(
2235            dup_init.contains("exactly once"),
2236            "DuplicateInitialize message should cite the spec invariant: {dup_init}",
2237        );
2238
2239        let handler = DispatchError::Handler("custom failure".into()).to_string();
2240        assert_eq!(handler, "custom failure");
2241    }
2242}