Skip to main content

omena_lsp_server/
message_loop.rs

1use crate::diagnostics_scheduler::{diagnostics_schedule_event, run_diagnostics_schedule_effects};
2use crate::lsp_output::ScheduledLspOutput;
3use crate::{
4    CANCEL_REQUEST_METHOD, CASCADE_AT_POSITION_REQUEST, DEBUG_STATE_REQUEST,
5    EXPLAIN_HOVER_TRACE_REQUEST, EXPLAIN_REQUEST, LspDeferredDiagnosticsDispatchV0,
6    LspQueryReadView, LspQuerySnapshotV0, LspShellState, LspWorkspaceIndexJobV0,
7    LspWorkspaceIndexResultV0, REQUEST_CANCELLED_ERROR_CODE, RUNTIME_LOOP_PROBE_REQUEST,
8    SDK_WORKFLOW_REQUEST, SOURCE_DIAGNOSTICS_REQUEST, STYLE_CONTEXT_INDEX_REQUEST,
9    STYLE_DIAGNOSTICS_REQUEST, STYLE_HOVER_CANDIDATES_REQUEST, apply_diagnostic_settings,
10    apply_feature_settings, apply_resolution_settings, current_node_lsp_capability_contract,
11    did_change_text_document, did_change_watched_files, did_change_workspace_folders,
12    did_close_text_document, did_open_text_document, index_workspace_style_files,
13    initialize_workspace_folders, prepare_background_workspace_index_job,
14    refresh_source_indexes_for_resolution_settings_change, resolve_cascade_at_position,
15    resolve_lsp_code_actions, resolve_lsp_code_lens, resolve_lsp_completion,
16    resolve_lsp_definition, resolve_lsp_explain, resolve_lsp_hover, resolve_lsp_hover_trace,
17    resolve_lsp_prepare_rename, resolve_lsp_references, resolve_lsp_rename,
18    resolve_source_diagnostics, resolve_style_context_index, resolve_style_diagnostics,
19    resolve_style_hover_candidates,
20};
21use serde_json::{Value, json};
22use std::time::{SystemTime, UNIX_EPOCH};
23
24pub fn handle_lsp_message(state: &mut LspShellState, message: Value) -> Option<Value> {
25    let method = message.get("method").and_then(Value::as_str);
26    let id = message.get("id").cloned();
27
28    if method == Some(CANCEL_REQUEST_METHOD) && id.is_none() {
29        cancel_lsp_request(state, message.get("params"));
30        return None;
31    }
32
33    if let Some(request_id) = id.as_ref()
34        && take_cancelled_request(state, request_id)
35    {
36        return Some(cancelled_request_response(request_id.clone()));
37    }
38
39    match (method, id) {
40        (Some("initialize"), Some(request_id)) => {
41            initialize_workspace_folders(state, message.get("params"));
42            Some(json!({
43                "jsonrpc": "2.0",
44                "id": request_id,
45                "result": {
46                    "capabilities": current_node_lsp_capability_contract(),
47                    "serverInfo": {
48                        "name": "omena-css-rust",
49                    },
50                },
51            }))
52        }
53        (Some("initialized"), None) => {
54            index_workspace_style_files(state);
55            None
56        }
57        (Some("textDocument/didOpen"), None) => {
58            did_open_text_document(state, message.get("params"));
59            None
60        }
61        (Some("textDocument/didChange"), None) => {
62            did_change_text_document(state, message.get("params"));
63            None
64        }
65        (Some("textDocument/didClose"), None) => {
66            did_close_text_document(state, message.get("params"));
67            None
68        }
69        (Some("workspace/didChangeWorkspaceFolders"), None) => {
70            did_change_workspace_folders(state, message.get("params"), true);
71            None
72        }
73        (Some("workspace/didChangeConfiguration"), None) => {
74            did_change_configuration(state, message.get("params"));
75            None
76        }
77        (Some("workspace/didChangeWatchedFiles"), None) => {
78            did_change_watched_files(state, message.get("params"));
79            None
80        }
81        (Some("textDocument/hover"), Some(request_id)) => Some(json!({
82            "jsonrpc": "2.0",
83            "id": request_id,
84            "result": if state.features.hover { resolve_lsp_hover(state, message.get("params")) } else { Value::Null },
85        })),
86        (Some("textDocument/definition"), Some(request_id)) => Some(json!({
87            "jsonrpc": "2.0",
88            "id": request_id,
89            "result": if state.features.definition { resolve_lsp_definition(state, message.get("params")) } else { Value::Null },
90        })),
91        (Some("textDocument/references"), Some(request_id)) => Some(json!({
92            "jsonrpc": "2.0",
93            "id": request_id,
94            "result": if state.features.references { resolve_lsp_references(state, message.get("params")) } else { Value::Null },
95        })),
96        (Some("textDocument/completion"), Some(request_id)) => Some(json!({
97            "jsonrpc": "2.0",
98            "id": request_id,
99            "result": if state.features.completion { resolve_lsp_completion(state, message.get("params")) } else { Value::Null },
100        })),
101        (Some("textDocument/codeAction"), Some(request_id)) => Some(json!({
102            "jsonrpc": "2.0",
103            "id": request_id,
104            "result": resolve_lsp_code_actions(state, message.get("params")),
105        })),
106        (Some("textDocument/documentColor"), Some(request_id)) => Some(json!({
107            "jsonrpc": "2.0",
108            "id": request_id,
109            "result": crate::color_provider::resolve_lsp_document_color(state, message.get("params")),
110        })),
111        (Some("textDocument/colorPresentation"), Some(request_id)) => Some(json!({
112            "jsonrpc": "2.0",
113            "id": request_id,
114            "result": crate::color_provider::resolve_lsp_color_presentation(message.get("params")),
115        })),
116        (Some("textDocument/documentLink"), Some(request_id)) => Some(json!({
117            "jsonrpc": "2.0",
118            "id": request_id,
119            "result": crate::document_links::resolve_lsp_document_links(state, message.get("params")),
120        })),
121        (Some("workspace/symbol"), Some(request_id)) => Some(json!({
122            "jsonrpc": "2.0",
123            "id": request_id,
124            "result": crate::workspace_symbols::resolve_lsp_workspace_symbols(state, message.get("params")),
125        })),
126        (Some("textDocument/codeLens"), Some(request_id)) => Some(json!({
127            "jsonrpc": "2.0",
128            "id": request_id,
129            "result": if state.features.references { resolve_lsp_code_lens(state, message.get("params")) } else { Value::Null },
130        })),
131        (Some("textDocument/prepareRename"), Some(request_id)) => Some(json!({
132            "jsonrpc": "2.0",
133            "id": request_id,
134            "result": if state.features.rename { resolve_lsp_prepare_rename(state, message.get("params")) } else { Value::Null },
135        })),
136        (Some("textDocument/rename"), Some(request_id)) => Some(json!({
137            "jsonrpc": "2.0",
138            "id": request_id,
139            "result": if state.features.rename { resolve_lsp_rename(state, message.get("params")) } else { Value::Null },
140        })),
141        (Some(DEBUG_STATE_REQUEST), Some(request_id)) => Some(json!({
142            "jsonrpc": "2.0",
143            "id": request_id,
144            "result": state.snapshot(),
145        })),
146        (Some(RUNTIME_LOOP_PROBE_REQUEST), Some(request_id)) => Some(json!({
147            "jsonrpc": "2.0",
148            "id": request_id,
149            "result": {
150                "now": current_time_millis(),
151            },
152        })),
153        (Some(STYLE_HOVER_CANDIDATES_REQUEST), Some(request_id)) => Some(json!({
154            "jsonrpc": "2.0",
155            "id": request_id,
156            "result": resolve_style_hover_candidates(state, message.get("params")),
157        })),
158        (Some(STYLE_DIAGNOSTICS_REQUEST), Some(request_id)) => Some(json!({
159            "jsonrpc": "2.0",
160            "id": request_id,
161            "result": resolve_style_diagnostics(state, message.get("params")),
162        })),
163        (Some(SOURCE_DIAGNOSTICS_REQUEST), Some(request_id)) => Some(json!({
164            "jsonrpc": "2.0",
165            "id": request_id,
166            "result": resolve_source_diagnostics(state, message.get("params")),
167        })),
168        (Some(CASCADE_AT_POSITION_REQUEST), Some(request_id)) => Some(json!({
169            "jsonrpc": "2.0",
170            "id": request_id,
171            "result": resolve_cascade_at_position(state, message.get("params")),
172        })),
173        (Some(STYLE_CONTEXT_INDEX_REQUEST), Some(request_id)) => Some(json!({
174            "jsonrpc": "2.0",
175            "id": request_id,
176            "result": resolve_style_context_index(state, message.get("params")),
177        })),
178        (Some(EXPLAIN_HOVER_TRACE_REQUEST), Some(request_id)) => Some(json!({
179            "jsonrpc": "2.0",
180            "id": request_id,
181            "result": resolve_lsp_hover_trace(state, message.get("params")),
182        })),
183        (Some(EXPLAIN_REQUEST), Some(request_id)) => Some(json!({
184            "jsonrpc": "2.0",
185            "id": request_id,
186            "result": resolve_lsp_explain(state, message.get("params")),
187        })),
188        (Some(SDK_WORKFLOW_REQUEST), Some(request_id)) => {
189            match crate::sdk_workflow::resolve_lsp_sdk_workflow(state, message.get("params")) {
190                Ok(result) => Some(json!({
191                    "jsonrpc": "2.0",
192                    "id": request_id,
193                    "result": result,
194                })),
195                Err(error) => Some(json!({
196                    "jsonrpc": "2.0",
197                    "id": request_id,
198                    "error": {
199                        "code": -32001,
200                        "message": error.message.clone(),
201                        "data": omena_query::OmenaSdkErrorEnvelopeV0 { error },
202                    },
203                })),
204            }
205        }
206        (Some("shutdown"), Some(request_id)) => {
207            state.shutdown_requested = true;
208            Some(json!({
209                "jsonrpc": "2.0",
210                "id": request_id,
211                "result": null,
212            }))
213        }
214        (Some("exit"), None) => {
215            state.should_exit = true;
216            None
217        }
218        (Some(_), Some(request_id)) => Some(json!({
219            "jsonrpc": "2.0",
220            "id": request_id,
221            "error": {
222                "code": -32601,
223                "message": "Method not found",
224            },
225        })),
226        (Some(_), None) => None,
227        (None, Some(request_id)) => {
228            if take_server_progress_response(state, &request_id) {
229                None
230            } else {
231                Some(json!({
232                    "jsonrpc": "2.0",
233                    "id": request_id,
234                    "error": {
235                        "code": -32600,
236                        "message": "Invalid Request",
237                    },
238                }))
239            }
240        }
241        (None, None) => None,
242    }
243}
244
245fn did_change_configuration(state: &mut LspShellState, params: Option<&Value>) {
246    state.configuration_change_count += 1;
247    let Some(settings) = params
248        .and_then(|value| value.get("settings"))
249        .and_then(|value| value.get("omena"))
250    else {
251        return;
252    };
253    apply_feature_settings(state, settings.get("features"));
254    if apply_diagnostic_settings(state, settings.get("diagnostics")) {
255        // Changed diagnostics settings owe a workspace republish: published
256        // diagnostics must re-render under the new severity / deep-analysis
257        // settings instead of staying stale indefinitely (rfcs#111 §2, the
258        // config-staleness fix). Settings-Eq changes cut off above.
259        state
260            .tide_ledger
261            .advance(&[crate::tide::TideInputKindV0::DiagnosticSettings]);
262        let tick = state.tide_tick;
263        state
264            .tide_republish_lane
265            .deposit(crate::tide::TideRepublishDemandV0::All, tick);
266    }
267    if apply_resolution_settings(state, settings.get("resolution")) {
268        state
269            .tide_ledger
270            .advance(&[crate::tide::TideInputKindV0::ResolutionSettings]);
271        refresh_source_indexes_for_resolution_settings_change(state);
272    }
273}
274
275fn cancel_lsp_request(state: &mut LspShellState, params: Option<&Value>) {
276    let Some(id) = params.and_then(|value| value.get("id")) else {
277        return;
278    };
279    if let Some(key) = request_id_key(id)
280        && !state.in_flight_requests.cancel(key.as_str())
281    {
282        state.cancelled_request_ids.cancel(key);
283    }
284}
285
286fn take_cancelled_request(state: &mut LspShellState, request_id: &Value) -> bool {
287    request_id_key(request_id).is_some_and(|key| {
288        state
289            .cancelled_request_ids
290            .take_cancelled_result(key.as_str())
291            .is_err()
292    })
293}
294
295fn take_server_progress_response(state: &mut LspShellState, request_id: &Value) -> bool {
296    request_id
297        .as_str()
298        .is_some_and(|id| state.take_server_progress_response(id))
299}
300
301fn request_id_key(id: &Value) -> Option<String> {
302    if let Some(value) = id.as_str() {
303        return Some(format!("s:{value}"));
304    }
305    if id.is_number() {
306        return Some(format!("n:{id}"));
307    }
308    None
309}
310
311fn cancelled_request_response(request_id: Value) -> Value {
312    json!({
313        "jsonrpc": "2.0",
314        "id": request_id,
315        "error": {
316            "code": REQUEST_CANCELLED_ERROR_CODE,
317            "message": "Request cancelled",
318        },
319    })
320}
321
322pub fn handle_lsp_message_outputs(state: &mut LspShellState, message: Value) -> Vec<Value> {
323    handle_lsp_message_scheduled_outputs(state, message)
324        .into_iter()
325        .map(ScheduledLspOutput::into_value)
326        .collect()
327}
328
329/// One loop turn for the stdio server.
330///
331/// `Outputs` is the synchronous path — every notification and every mutating or
332/// loop-owned request keeps its existing FIFO behaviour. `DispatchQuery` hands
333/// the heaviest read-only request class (`textDocument/hover` and
334/// `textDocument/definition`, the 75/95 of the runtime-loop burst) to a worker
335/// together with a copy-on-write snapshot taken HERE on the loop thread, so the
336/// loop turn for that class collapses to read + O(documents) pointer clones +
337/// channel send.
338#[derive(Debug)]
339pub enum LspLoopTurnV0 {
340    Outputs(Vec<ScheduledLspOutput>),
341    OutputsAndDeferredDiagnostics {
342        outputs: Vec<ScheduledLspOutput>,
343        deferred_diagnostics: Vec<LspDeferredDiagnosticsDispatchV0>,
344        workspace_index_jobs: Vec<LspWorkspaceIndexJobV0>,
345    },
346    // Boxed: a dispatch carries the whole snapshot (settings + documents map),
347    // which would otherwise dominate the enum size for the common Outputs turn.
348    DispatchQuery(Box<LspQueryDispatchV0>),
349}
350
351/// A dispatched read-only request paired with its loop-consistent snapshot and
352/// optional client-request completion token.
353#[derive(Debug)]
354pub struct LspQueryDispatchV0 {
355    pub snapshot: LspQuerySnapshotV0,
356    pub message: Value,
357    pub(crate) completion: Option<crate::state::LspDispatchedRequestToken>,
358}
359
360/// Loop-side turn handler for the stdio server. Mirrors
361/// [`handle_lsp_message_scheduled_outputs`] except that dispatchable query
362/// requests are returned as jobs instead of being resolved inline.
363///
364/// The `$/cancelRequest` gate stays loop-side. A request cancelled before it
365/// arrives is answered here and never dispatched. Once dispatched, its shared
366/// lifecycle token records cancellation so the worker can replace the computed
367/// payload with `RequestCancelled` at the completion boundary.
368pub fn handle_lsp_message_scheduled_outputs_or_dispatch(
369    state: &mut LspShellState,
370    message: Value,
371) -> LspLoopTurnV0 {
372    if let Some(request_id) = dispatchable_query_request_id(&message) {
373        if take_cancelled_request(state, &request_id) {
374            return LspLoopTurnV0::Outputs(vec![ScheduledLspOutput::immediate(
375                cancelled_request_response(request_id),
376            )]);
377        }
378        // documentColor on a document with no variable-reference candidate
379        // answers [] right here: the editor merges ALL color providers
380        // before painting swatches, so routing a trivially-empty answer
381        // through the worker queue would gate the built-in literal chips
382        // on our round-trip. The check reads the precomputed per-document
383        // candidates only.
384        if message.get("method").and_then(Value::as_str) == Some("textDocument/documentColor")
385            && !crate::color_provider::document_has_color_reference_candidates(
386                state,
387                message.get("params"),
388            )
389        {
390            return LspLoopTurnV0::Outputs(vec![ScheduledLspOutput::immediate(json!({
391                "jsonrpc": "2.0",
392                "id": request_id,
393                "result": json!([]),
394            }))]);
395        }
396        let Some(request_key) = request_id_key(&request_id) else {
397            return LspLoopTurnV0::Outputs(vec![ScheduledLspOutput::immediate(json!({
398                "jsonrpc": "2.0",
399                "id": request_id,
400                "error": {
401                    "code": -32600,
402                    "message": "Invalid Request",
403                },
404            }))]);
405        };
406        let completion = state.in_flight_requests.register(request_key);
407        return LspLoopTurnV0::DispatchQuery(Box::new(LspQueryDispatchV0 {
408            snapshot: state.query_snapshot(),
409            message,
410            completion: Some(completion),
411        }));
412    }
413    let effects = handle_lsp_message_scheduled_effects_with_deferral(state, message, true, true);
414    if effects.deferred_diagnostics.is_empty() && effects.workspace_index_jobs.is_empty() {
415        LspLoopTurnV0::Outputs(effects.outputs)
416    } else {
417        LspLoopTurnV0::OutputsAndDeferredDiagnostics {
418            outputs: effects.outputs,
419            deferred_diagnostics: effects.deferred_diagnostics,
420            workspace_index_jobs: effects.workspace_index_jobs,
421        }
422    }
423}
424
425/// The dispatched request class: hover/definition REQUESTS (an `id` is
426/// required — without one there is nothing to respond to). Notifications named
427/// like these methods fall through to the synchronous path unchanged.
428/// JSON-RPC internal-error response for a dispatched query whose resolver
429/// panicked on the worker: the request still gets exactly one response (a
430/// silent drop would hang the client), and the worker survives to serve the
431/// rest of its queue.
432pub fn dispatched_query_internal_error_response(dispatch: &LspQueryDispatchV0) -> Option<Value> {
433    let request_id = dispatchable_query_request_id(&dispatch.message)?;
434    Some(json!({
435        "jsonrpc": "2.0",
436        "id": request_id,
437        "error": {
438            "code": -32603,
439            "message": "internal error while resolving the dispatched query",
440        },
441    }))
442}
443
444fn dispatchable_query_request_id(message: &Value) -> Option<Value> {
445    let method = message.get("method").and_then(Value::as_str)?;
446    if method != "textDocument/hover"
447        && method != "textDocument/definition"
448        && method != "textDocument/documentColor"
449        && method != "textDocument/documentLink"
450        && method != "workspace/symbol"
451        && method != "textDocument/codeLens"
452    {
453        return None;
454    }
455    message.get("id").cloned()
456}
457
458/// Lane routing for dispatched queries: HEAVY dispatches (codeLens, whose
459/// occurrence-index rebuild is a whole-corpus scan, and the internal
460/// substrate warmups) run on their own worker so an interactive hover /
461/// definition / documentColor never queues behind a multi-second compute.
462/// Head-of-line blocking on the single lane was measured live: a
463/// documentColor answered 3ms of work 20 SECONDS after dispatch because a
464/// codeLens rebuild held the worker.
465pub fn dispatched_query_is_heavy(dispatch: &LspQueryDispatchV0) -> bool {
466    matches!(
467        dispatch.message.get("method").and_then(Value::as_str),
468        Some("textDocument/codeLens") | Some(HOVER_SUBSTRATE_WARMUP_METHOD)
469    )
470}
471
472/// Worker-side resolution of a dispatched query request. Mirrors the
473/// synchronous `handle_lsp_message` arms exactly, including the feature gating
474/// (evaluated against the snapshot, i.e. the settings in force at dispatch
475/// time). Returns the complete JSON-RPC response; `None` only for messages that
476/// were never dispatchable (defensive — the loop only dispatches
477/// hover/definition requests).
478/// Internal dispatch (no client-visible response): resolve one hover
479/// against the snapshot purely to POPULATE the Arc-shared hover memos
480/// (cascade-narrowing substrate, resolver identity index) right after a
481/// republish tide settles — the user's FIRST hover then lands warm instead
482/// of paying the substrate build interactively.
483pub const HOVER_SUBSTRATE_WARMUP_METHOD: &str = "omena/internalWarmHoverSubstrate";
484
485/// The post-settle warmup dispatch: the first OPEN style document's first
486/// hover candidate. One document suffices — the substrate the build warms
487/// is workspace-scoped, not per-document.
488pub fn hover_substrate_warmup_dispatch(state: &LspShellState) -> Option<Box<LspQueryDispatchV0>> {
489    let document = state
490        .open_document_uris
491        .iter()
492        .filter_map(|file_id| state.document_for_file_id(*file_id))
493        .find(|document| {
494            crate::protocol::is_style_document_uri(document.uri.as_str())
495                && !document.style_candidates.is_empty()
496        })?;
497    let candidate = document.style_candidates.first()?;
498    let message = json!({
499        "jsonrpc": "2.0",
500        "method": HOVER_SUBSTRATE_WARMUP_METHOD,
501        "params": {
502            "textDocument": { "uri": document.uri },
503            "position": candidate.range.start,
504        },
505    });
506    Some(Box::new(LspQueryDispatchV0 {
507        snapshot: state.query_snapshot(),
508        message,
509        completion: None,
510    }))
511}
512
513/// Choose the single client-visible response at the worker completion boundary.
514///
515/// Resolver computation is not preempted. If cancellation won the lifecycle
516/// race, its computed payload is suppressed and replaced with the protocol
517/// cancellation error. Re-entering this boundary for the same dispatch emits
518/// nothing, which prevents duplicate replies.
519pub fn complete_dispatched_query_response(
520    dispatch: &LspQueryDispatchV0,
521    response: Option<Value>,
522) -> Option<Value> {
523    let Some(completion) = dispatch.completion.as_ref() else {
524        return response;
525    };
526    match completion.complete() {
527        crate::state::LspDispatchedRequestCompletion::Result => response,
528        crate::state::LspDispatchedRequestCompletion::Cancelled => {
529            dispatchable_query_request_id(&dispatch.message).map(cancelled_request_response)
530        }
531        crate::state::LspDispatchedRequestCompletion::AlreadyCompleted => None,
532    }
533}
534
535pub fn resolve_dispatched_query_response(dispatch: &LspQueryDispatchV0) -> Option<Value> {
536    if dispatch.message.get("method").and_then(Value::as_str) == Some(HOVER_SUBSTRATE_WARMUP_METHOD)
537    {
538        let started = std::time::Instant::now();
539        let state: &dyn LspQueryReadView = &dispatch.snapshot;
540        let _ = resolve_lsp_hover(state, dispatch.message.get("params"));
541        crate::loop_trace!(
542            "hover-warmup done took_ms={}",
543            started.elapsed().as_millis()
544        );
545        return None;
546    }
547    let request_id = dispatchable_query_request_id(&dispatch.message)?;
548    let method = dispatch.message.get("method").and_then(Value::as_str)?;
549    let params = dispatch.message.get("params");
550    let state: &dyn LspQueryReadView = &dispatch.snapshot;
551    let result = match method {
552        "textDocument/hover" => {
553            if state.query_features().hover {
554                resolve_lsp_hover(state, params)
555            } else {
556                Value::Null
557            }
558        }
559        "textDocument/definition" => {
560            if state.query_features().definition {
561                resolve_lsp_definition(state, params)
562            } else {
563                Value::Null
564            }
565        }
566        // Off-loop by design: specifier resolution may probe disk.
567        "textDocument/documentLink" => {
568            crate::document_links::resolve_lsp_document_links(state, params)
569        }
570        // Read-only sweep over precomputed per-document candidates.
571        "workspace/symbol" => {
572            crate::workspace_symbols::resolve_lsp_workspace_symbols(state, params)
573        }
574        // Off-loop by design: the resolution walk can read unadmitted
575        // dependency chains from disk, which must never block the loop.
576        "textDocument/documentColor" => {
577            let started = std::time::Instant::now();
578            let result = crate::color_provider::resolve_lsp_document_color(state, params);
579            crate::loop_trace!(
580                "document-color dispatched took_ms={}",
581                started.elapsed().as_millis()
582            );
583            result
584        }
585        // Off-loop by design: a memo miss rebuilds the workspace occurrence
586        // index — a whole-corpus scan measured in the tens of seconds on a
587        // real workspace, which used to land on the loop. The memo is
588        // Arc-shared with the loop and the other workers, so one rebuild
589        // serves every consumer until the document keys move.
590        "textDocument/codeLens" => {
591            if state.query_features().references {
592                let started = std::time::Instant::now();
593                let result = resolve_lsp_code_lens(state, params);
594                crate::loop_trace!(
595                    "code-lens dispatched took_ms={}",
596                    started.elapsed().as_millis()
597                );
598                result
599            } else {
600                Value::Null
601            }
602        }
603        _ => return None,
604    };
605    Some(json!({
606        "jsonrpc": "2.0",
607        "id": request_id,
608        "result": result,
609    }))
610}
611
612pub fn handle_lsp_message_scheduled_outputs(
613    state: &mut LspShellState,
614    message: Value,
615) -> Vec<ScheduledLspOutput> {
616    handle_lsp_message_scheduled_effects_with_deferral(state, message, false, false).outputs
617}
618
619fn handle_lsp_message_scheduled_effects_with_deferral(
620    state: &mut LspShellState,
621    message: Value,
622    enable_deferred_style_diagnostics: bool,
623    enable_background_workspace_index: bool,
624) -> LspScheduledEffectsV0 {
625    let method = message
626        .get("method")
627        .and_then(Value::as_str)
628        .map(str::to_string);
629    let document_uri = message
630        .get("params")
631        .and_then(|value| value.get("textDocument"))
632        .and_then(|value| value.get("uri"))
633        .and_then(Value::as_str)
634        .map(str::to_string);
635    let watched_file_uris = watched_file_uris_from_message(&message);
636    // Computed BEFORE the handler mutates state: for a didOpen, the state
637    // still holds the index-admitted document, so byte-identical text is
638    // detectable here and nowhere later.
639    let content_changed = if method.as_deref() == Some("textDocument/didOpen") {
640        let incoming_text = message
641            .pointer("/params/textDocument/text")
642            .and_then(Value::as_str);
643        match (
644            document_uri.as_deref().and_then(|uri| state.document(uri)),
645            incoming_text,
646        ) {
647            (Some(existing), Some(text)) => existing.text != text,
648            _ => true,
649        }
650    } else {
651        true
652    };
653    let diagnostics_event = diagnostics_schedule_event(
654        method.as_deref(),
655        document_uri,
656        watched_file_uris,
657        content_changed,
658    );
659    let mut effects = LspScheduledEffectsV0::default();
660
661    let response = if enable_background_workspace_index {
662        handle_lsp_message_for_background_workspace_index(state, &message, &mut effects)
663    } else {
664        handle_lsp_message(state, message)
665    };
666
667    if let Some(response) = response {
668        effects
669            .outputs
670            .push(ScheduledLspOutput::immediate(response));
671    }
672
673    if let Some(event) = diagnostics_event {
674        let diagnostics_effects = if enable_deferred_style_diagnostics {
675            run_diagnostics_schedule_effects(state, event)
676        } else {
677            crate::diagnostics_scheduler::DiagnosticsScheduleEffectsV0::from_outputs(
678                crate::diagnostics_scheduler::run_diagnostics_schedule(state, event),
679            )
680        };
681        effects.outputs.extend(diagnostics_effects.outputs);
682        effects
683            .deferred_diagnostics
684            .extend(diagnostics_effects.deferred_diagnostics);
685    }
686
687    effects
688}
689
690#[derive(Debug, Default)]
691pub struct LspScheduledEffectsV0 {
692    pub outputs: Vec<ScheduledLspOutput>,
693    pub deferred_diagnostics: Vec<LspDeferredDiagnosticsDispatchV0>,
694    pub workspace_index_jobs: Vec<LspWorkspaceIndexJobV0>,
695}
696
697impl From<Vec<ScheduledLspOutput>> for LspScheduledEffectsV0 {
698    fn from(outputs: Vec<ScheduledLspOutput>) -> Self {
699        Self {
700            outputs,
701            deferred_diagnostics: Vec::new(),
702            workspace_index_jobs: Vec::new(),
703        }
704    }
705}
706
707fn handle_lsp_message_for_background_workspace_index(
708    state: &mut LspShellState,
709    message: &Value,
710    effects: &mut LspScheduledEffectsV0,
711) -> Option<Value> {
712    match message.get("method").and_then(Value::as_str) {
713        Some("initialized") if message.get("id").is_none() => {
714            let mut job = prepare_background_workspace_index_job(state);
715            effects
716                .outputs
717                .extend(workspace_index_progress_begin_outputs(state, &mut job));
718            effects.workspace_index_jobs.push(job);
719            None
720        }
721        Some("workspace/didChangeWorkspaceFolders") if message.get("id").is_none() => {
722            let added_workspace_folder =
723                did_change_workspace_folders(state, message.get("params"), false);
724            if added_workspace_folder {
725                let mut job = prepare_background_workspace_index_job(state);
726                effects
727                    .outputs
728                    .extend(workspace_index_progress_begin_outputs(state, &mut job));
729                effects.workspace_index_jobs.push(job);
730            }
731            None
732        }
733        _ => handle_lsp_message(state, message.clone()),
734    }
735}
736
737fn workspace_index_progress_begin_outputs(
738    state: &mut LspShellState,
739    job: &mut LspWorkspaceIndexJobV0,
740) -> Vec<ScheduledLspOutput> {
741    if !state.client_supports_work_done_progress {
742        return Vec::new();
743    }
744
745    let (id, token) = state.allocate_work_done_progress_request();
746    job.progress_token = Some(token.clone());
747    vec![
748        ScheduledLspOutput::immediate(json!({
749            "jsonrpc": "2.0",
750            "id": id,
751            "method": "window/workDoneProgress/create",
752            "params": {
753                "token": token,
754            },
755        })),
756        ScheduledLspOutput::immediate(json!({
757            "jsonrpc": "2.0",
758            "method": "$/progress",
759            "params": {
760                "token": token,
761                "value": {
762                    "kind": "begin",
763                    "title": "Omena CSS workspace index",
764                    "message": "Scanning workspace files",
765                },
766            },
767        })),
768    ]
769}
770
771pub fn workspace_index_progress_end_output(
772    result: &LspWorkspaceIndexResultV0,
773) -> Option<ScheduledLspOutput> {
774    let token = result.progress_token.as_deref()?;
775    let message = if result.exhausted && result.pending_file_count > 0 {
776        format!(
777            "Workspace index updated; continuing with {} remaining files in the background",
778            result.pending_file_count
779        )
780    } else {
781        "Workspace index updated".to_string()
782    };
783    Some(ScheduledLspOutput::immediate(json!({
784        "jsonrpc": "2.0",
785        "method": "$/progress",
786        "params": {
787            "token": token,
788            "value": {
789                "kind": "end",
790                "message": message,
791            },
792        },
793    })))
794}
795
796pub(crate) fn current_time_millis() -> u128 {
797    SystemTime::now()
798        .duration_since(UNIX_EPOCH)
799        .map_or(0, |duration| duration.as_millis())
800}
801
802fn watched_file_uris_from_message(message: &Value) -> Vec<String> {
803    message
804        .get("params")
805        .and_then(|value| value.get("changes"))
806        .and_then(Value::as_array)
807        .map(|changes| {
808            changes
809                .iter()
810                .filter_map(|change| change.get("uri").and_then(Value::as_str))
811                .map(str::to_string)
812                .collect()
813        })
814        .unwrap_or_default()
815}