Skip to main content

omena_lsp_server/
lib.rs

1mod boundary;
2mod diagnostics_scheduler;
3mod frame_aware_refresh;
4mod message_loop;
5mod protocol;
6mod query_adapter;
7mod query_reuse;
8mod settings;
9mod source_type_facts;
10mod state;
11mod workspace_index;
12mod workspace_runtime_registry;
13
14pub use boundary::*;
15pub use frame_aware_refresh::*;
16#[cfg(test)]
17pub(crate) use message_loop::current_time_millis;
18pub use message_loop::{handle_lsp_message, handle_lsp_message_outputs};
19use omena_query::{
20    OmenaQueryCodeActionV0, OmenaQueryCompletionCandidateV0, OmenaQueryCompletionItemV0,
21    OmenaQueryEngineInputV2, OmenaQueryExternalModuleModeV0, OmenaQuerySourceDocumentInputV0,
22    OmenaQuerySourceImportedStyleBindingV0 as ImportedStyleBinding,
23    OmenaQuerySourceMissingSelectorDiagnosticCandidateV0,
24    OmenaQuerySourceSelectorReferenceFactV0 as SourceSelectorReferenceFact,
25    OmenaQuerySourceSelectorReferenceMatchKindV0 as SourceSelectorReferenceMatchKind,
26    OmenaQuerySourceSyntaxIndexV0 as SourceSyntaxIndex, OmenaQueryStyleSourceInputV0,
27    ParserByteSpanV0, ParserPositionV0, ParserRangeV0, StyleLanguage,
28    collect_omena_query_vue_style_module_bindings,
29    is_omena_query_sass_symbol_candidate_kind as is_sass_symbol_candidate_kind,
30    is_omena_query_sass_symbol_declaration_kind as is_sass_symbol_declaration_kind,
31    is_omena_query_sass_symbol_reference_kind as is_sass_symbol_reference_kind,
32    load_omena_query_workspace_style_resolution_inputs,
33    omena_query_sass_symbol_kind_from_candidate_kind as sass_symbol_kind_from_candidate_kind,
34    omena_query_sass_symbol_target_matches,
35    read_omena_query_cascade_at_position_with_categorical_evidence,
36    read_omena_query_style_context_index, resolve_omena_query_sass_forward_sources,
37    resolve_omena_query_sass_module_use_sources_for_candidate,
38    resolve_omena_query_sass_symbol_declarations,
39    resolve_omena_query_source_candidate_selector_names,
40    resolve_omena_query_source_provider_candidates,
41    resolve_omena_query_style_selector_definitions_for_source_candidate,
42    resolve_omena_query_style_uri_for_specifier_with_resolution_inputs,
43    summarize_omena_query_refs_for_class, summarize_omena_query_rename_plan,
44    summarize_omena_query_sass_module_sources, summarize_omena_query_source_completion_at_position,
45    summarize_omena_query_source_diagnostics_for_file,
46    summarize_omena_query_source_import_declarations_for_source_language,
47    summarize_omena_query_source_syntax_index_for_source_language,
48    summarize_omena_query_style_completion_at_position,
49    summarize_omena_query_style_diagnostics_for_file,
50    summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode_and_sifs_and_resolution_inputs,
51    summarize_omena_query_style_document, summarize_omena_query_style_extract_code_actions,
52    summarize_omena_query_style_hover_render_parts,
53    summarize_omena_query_style_inline_code_actions,
54};
55#[cfg(test)]
56pub(crate) use omena_tsgo_client::{TsgoResolvedTypeV0, TsgoTypeFactResultEntryV0};
57use protocol::*;
58use query_adapter::*;
59use query_reuse::refresh_document_reusable_indexes;
60use serde_json::{Value, json};
61pub(crate) use settings::{
62    apply_diagnostic_settings, apply_feature_settings, apply_resolution_settings,
63};
64#[cfg(test)]
65pub(crate) use source_type_facts::apply_source_type_fact_results_to_document;
66pub(crate) use source_type_facts::refresh_source_type_fact_candidates_for_document;
67pub use state::*;
68#[cfg(test)]
69use std::path::Path;
70use std::{
71    collections::{BTreeMap, BTreeSet},
72    fs,
73};
74pub(crate) use workspace_index::index_workspace_style_files;
75#[cfg(test)]
76pub(crate) use workspace_index::{
77    WorkspaceStyleIndexBudget, index_workspace_style_files_with_budget,
78};
79
80pub const NODE_TEXT_DOCUMENT_SYNC_KIND: u8 = 2;
81pub const DEBUG_STATE_REQUEST: &str = "omena/rustLspState";
82pub const RUNTIME_LOOP_PROBE_REQUEST: &str = "omena/runtimeLoopProbe";
83pub const STYLE_HOVER_CANDIDATES_REQUEST: &str = "omena/rustStyleHoverCandidates";
84pub const STYLE_DIAGNOSTICS_REQUEST: &str = "omena/rustStyleDiagnostics";
85pub const SOURCE_DIAGNOSTICS_REQUEST: &str = "omena/rustSourceDiagnostics";
86pub const CASCADE_AT_POSITION_REQUEST: &str = "omena/rustCascadeAtPosition";
87pub const STYLE_CONTEXT_INDEX_REQUEST: &str = "omena/rustStyleContextIndex";
88const CANCEL_REQUEST_METHOD: &str = "$/cancelRequest";
89const REQUEST_CANCELLED_ERROR_CODE: i32 = -32800;
90#[derive(Debug, Clone, PartialEq, Eq)]
91struct SourceProviderCandidateResolution {
92    matched: Vec<LspStyleHoverCandidate>,
93    unresolved: Vec<LspStyleHoverCandidate>,
94}
95
96fn initialize_workspace_folders(state: &mut LspShellState, params: Option<&Value>) {
97    state.workspace_runtime_registry.clear();
98    if let Some(folders) = params
99        .and_then(|value| value.get("workspaceFolders"))
100        .and_then(Value::as_array)
101    {
102        for folder in folders {
103            insert_workspace_folder(state, folder);
104        }
105        refresh_workspace_resolution_inputs(state);
106        return;
107    }
108
109    if let Some(root_uri) = params
110        .and_then(|value| value.get("rootUri"))
111        .and_then(Value::as_str)
112    {
113        state
114            .workspace_runtime_registry
115            .insert(root_uri.to_string(), root_uri.to_string());
116    }
117    refresh_workspace_resolution_inputs(state);
118}
119
120fn refresh_workspace_resolution_inputs(state: &mut LspShellState) {
121    let configured_package_manifests = state.resolution.package_manifests.clone();
122    let workspace_uris = state
123        .workspace_runtime_registry
124        .folder_snapshots()
125        .into_iter()
126        .map(|folder| folder.uri)
127        .collect::<BTreeSet<_>>();
128    state
129        .resolution
130        .workspace_style_resolution_inputs
131        .retain(|workspace_uri, _| workspace_uris.contains(workspace_uri));
132    for workspace_uri in workspace_uris {
133        let inputs = load_omena_query_workspace_style_resolution_inputs(
134            Some(workspace_uri.as_str()),
135            configured_package_manifests.as_slice(),
136        );
137        state
138            .resolution
139            .workspace_style_resolution_inputs
140            .insert(workspace_uri, inputs);
141    }
142}
143
144fn refresh_workspace_resolution_inputs_for_uri(state: &mut LspShellState, uri: &str) {
145    let Some(workspace_uri) = resolve_workspace_folder_uri(state, uri) else {
146        return;
147    };
148    let inputs = load_omena_query_workspace_style_resolution_inputs(
149        Some(workspace_uri.as_str()),
150        state.resolution.package_manifests.as_slice(),
151    );
152    state
153        .resolution
154        .workspace_style_resolution_inputs
155        .insert(workspace_uri, inputs);
156}
157
158fn resolution_inputs_for_workspace_uri(
159    state: &LspShellState,
160    workspace_folder_uri: Option<&str>,
161) -> omena_query::OmenaQueryStyleResolutionInputsV0 {
162    workspace_folder_uri
163        .and_then(|workspace_uri| {
164            state
165                .resolution
166                .workspace_style_resolution_inputs
167                .get(workspace_uri)
168        })
169        .cloned()
170        .unwrap_or_else(|| omena_query::OmenaQueryStyleResolutionInputsV0 {
171            package_manifests: state.resolution.package_manifests.clone(),
172            ..Default::default()
173        })
174}
175
176fn lsp_text_document_state(
177    uri: String,
178    workspace_folder_uri: Option<String>,
179    language_id: String,
180    version: i64,
181    text: String,
182    resolution_inputs: &omena_query::OmenaQueryStyleResolutionInputsV0,
183) -> LspTextDocumentState {
184    let mut document = LspTextDocumentState {
185        uri,
186        workspace_folder_uri,
187        language_id,
188        version,
189        text,
190        style_summary: None,
191        style_candidates: Vec::new(),
192        source_syntax_index: SourceSyntaxIndex::default(),
193        source_selector_candidates: Vec::new(),
194    };
195    refresh_document_reusable_indexes(&mut document, resolution_inputs);
196    document
197}
198
199fn did_open_text_document(state: &mut LspShellState, params: Option<&Value>) {
200    let Some(document) = params.and_then(|value| value.get("textDocument")) else {
201        return;
202    };
203    let Some(uri) = document.get("uri").and_then(Value::as_str) else {
204        return;
205    };
206
207    state.insert_open_document_uri(uri);
208    let workspace_folder_uri = resolve_workspace_folder_uri(state, uri);
209    let resolution_inputs =
210        resolution_inputs_for_workspace_uri(state, workspace_folder_uri.as_deref());
211    state.insert_document(
212        uri,
213        lsp_text_document_state(
214            uri.to_string(),
215            workspace_folder_uri,
216            document
217                .get("languageId")
218                .and_then(Value::as_str)
219                .unwrap_or("unknown")
220                .to_string(),
221            document.get("version").and_then(Value::as_i64).unwrap_or(0),
222            document
223                .get("text")
224                .and_then(Value::as_str)
225                .unwrap_or_default()
226                .to_string(),
227            &resolution_inputs,
228        ),
229    );
230    if is_style_document_uri(uri) {
231        refresh_source_type_fact_candidates_for_referencing_documents(state, uri);
232    } else {
233        refresh_source_type_fact_candidates_for_document(state, uri);
234    }
235}
236
237fn did_change_text_document(state: &mut LspShellState, params: Option<&Value>) {
238    let Some(text_document) = params.and_then(|value| value.get("textDocument")) else {
239        return;
240    };
241    let Some(uri) = text_document.get("uri").and_then(Value::as_str) else {
242        return;
243    };
244    let resolution_inputs = state
245        .document(uri)
246        .map(|document| {
247            resolution_inputs_for_workspace_uri(state, document.workspace_folder_uri.as_deref())
248        })
249        .unwrap_or_else(|| resolution_inputs_for_workspace_uri(state, None));
250    let Some(existing) = state.document_mut(uri) else {
251        return;
252    };
253
254    if let Some(version) = text_document.get("version").and_then(Value::as_i64) {
255        existing.version = version;
256    }
257    let Some(changes) = params
258        .and_then(|value| value.get("contentChanges"))
259        .and_then(Value::as_array)
260    else {
261        return;
262    };
263
264    let mut text_changed = false;
265    for change in changes {
266        if apply_text_document_content_change(existing, change) {
267            text_changed = true;
268        }
269    }
270    if text_changed {
271        refresh_document_reusable_indexes(existing, &resolution_inputs);
272    }
273    if text_changed {
274        if is_style_document_uri(uri) {
275            refresh_source_type_fact_candidates_for_referencing_documents(state, uri);
276        } else {
277            refresh_source_type_fact_candidates_for_document(state, uri);
278        }
279    }
280}
281
282fn apply_text_document_content_change(document: &mut LspTextDocumentState, change: &Value) -> bool {
283    let Some(next_text) = change.get("text").and_then(Value::as_str) else {
284        return false;
285    };
286    let Some(range) = change.get("range").and_then(lsp_range_from_value) else {
287        document.text = next_text.to_string();
288        return true;
289    };
290    let Some(start_offset) = byte_offset_for_parser_position(document.text.as_str(), range.start)
291    else {
292        return false;
293    };
294    let Some(end_offset) = byte_offset_for_parser_position(document.text.as_str(), range.end)
295    else {
296        return false;
297    };
298    if start_offset > end_offset {
299        return false;
300    }
301    document
302        .text
303        .replace_range(start_offset..end_offset, next_text);
304    true
305}
306
307fn did_close_text_document(state: &mut LspShellState, params: Option<&Value>) {
308    let Some(uri) = params
309        .and_then(|value| value.get("textDocument"))
310        .and_then(|value| value.get("uri"))
311        .and_then(Value::as_str)
312    else {
313        return;
314    };
315    state.remove_open_document_uri(uri);
316    if is_style_document_uri(uri) && reload_indexed_style_document_from_disk(state, uri) {
317        refresh_source_type_fact_candidates_for_referencing_documents(state, uri);
318        return;
319    }
320    state.remove_document_uri(uri);
321    if is_style_document_uri(uri) {
322        refresh_source_type_fact_candidates_for_referencing_documents(state, uri);
323    }
324}
325
326fn did_change_workspace_folders(state: &mut LspShellState, params: Option<&Value>) {
327    let event = params.and_then(|value| value.get("event"));
328    let mut removed_workspace_uris = Vec::new();
329    if let Some(removed) = event
330        .and_then(|value| value.get("removed"))
331        .and_then(Value::as_array)
332    {
333        for folder in removed {
334            if let Some(uri) = folder.get("uri").and_then(Value::as_str) {
335                state.workspace_runtime_registry.remove(uri);
336                removed_workspace_uris.push(uri.to_string());
337            }
338        }
339    }
340    let mut added_workspace_folder = false;
341    if let Some(added) = event
342        .and_then(|value| value.get("added"))
343        .and_then(Value::as_array)
344    {
345        for folder in added {
346            insert_workspace_folder(state, folder);
347            added_workspace_folder = true;
348        }
349    }
350    reconcile_documents_after_workspace_folder_changes(state, removed_workspace_uris.as_slice());
351    refresh_workspace_resolution_inputs(state);
352    if added_workspace_folder {
353        index_workspace_style_files(state);
354    }
355}
356
357fn reconcile_documents_after_workspace_folder_changes(
358    state: &mut LspShellState,
359    removed_workspace_uris: &[String],
360) {
361    remove_unowned_indexed_documents_for_removed_workspaces(state, removed_workspace_uris);
362    refresh_document_workspace_owners(state);
363}
364
365fn remove_unowned_indexed_documents_for_removed_workspaces(
366    state: &mut LspShellState,
367    removed_workspace_uris: &[String],
368) {
369    if removed_workspace_uris.is_empty() {
370        return;
371    }
372    let open_document_uris = state.open_document_uris.clone();
373    let workspace_runtime_registry = state.workspace_runtime_registry.clone();
374    state.documents.retain(|uri, document| {
375        if open_document_uris.contains(uri)
376            || open_document_uris
377                .iter()
378                .any(|open_uri| file_uri_equivalent(open_uri, uri))
379        {
380            return true;
381        }
382        let owned_by_removed_workspace =
383            document
384                .workspace_folder_uri
385                .as_deref()
386                .is_some_and(|workspace_uri| {
387                    removed_workspace_uris
388                        .iter()
389                        .any(|removed_uri| removed_uri == workspace_uri)
390                });
391        !owned_by_removed_workspace
392            || workspace_runtime_registry
393                .resolve_owner_uri(uri.as_str())
394                .is_some()
395    });
396}
397
398fn did_change_watched_files(state: &mut LspShellState, params: Option<&Value>) {
399    let Some(changes) = params
400        .and_then(|value| value.get("changes"))
401        .and_then(Value::as_array)
402    else {
403        return;
404    };
405    for change in changes {
406        let Some(uri) = change.get("uri").and_then(Value::as_str) else {
407            continue;
408        };
409        let change_type = change.get("type").and_then(Value::as_u64).unwrap_or(0);
410        state.watched_file_changes.push(LspWatchedFileChangeState {
411            uri: uri.to_string(),
412            change_type,
413        });
414        apply_watched_file_change_to_index(state, uri, change_type);
415    }
416}
417
418fn apply_watched_file_change_to_index(state: &mut LspShellState, uri: &str, change_type: u64) {
419    if !is_style_document_uri(uri) {
420        if is_resolution_config_document_uri(uri) {
421            refresh_source_indexes_for_resolution_config_change(state, uri);
422        }
423        return;
424    }
425    if state.has_open_document_uri(uri) {
426        return;
427    }
428    if change_type == 3 {
429        state.remove_document_uri(uri);
430        refresh_source_type_fact_candidates_for_referencing_documents(state, uri);
431        return;
432    }
433
434    if reload_indexed_style_document_from_disk(state, uri) {
435        refresh_source_type_fact_candidates_for_referencing_documents(state, uri);
436    }
437}
438
439fn refresh_source_indexes_for_resolution_config_change(
440    state: &mut LspShellState,
441    config_uri: &str,
442) {
443    refresh_workspace_resolution_inputs_for_uri(state, config_uri);
444    let workspace_folder_uri = resolve_workspace_folder_uri(state, config_uri);
445    let source_uris = state
446        .documents
447        .values()
448        .filter(|document| !is_style_document_uri(document.uri.as_str()))
449        .filter(|document| {
450            workspace_folder_uri.as_deref().is_none_or(|workspace_uri| {
451                workspace_folder_compatible(Some(workspace_uri), document)
452            })
453        })
454        .map(|document| document.uri.clone())
455        .collect::<Vec<_>>();
456    for source_uri in source_uris {
457        let resolution_inputs = state
458            .document(source_uri.as_str())
459            .map(|document| {
460                resolution_inputs_for_workspace_uri(state, document.workspace_folder_uri.as_deref())
461            })
462            .unwrap_or_else(|| {
463                resolution_inputs_for_workspace_uri(state, workspace_folder_uri.as_deref())
464            });
465        if let Some(document) = state.document_mut(source_uri.as_str()) {
466            refresh_document_reusable_indexes(document, &resolution_inputs);
467        }
468        refresh_source_type_fact_candidates_for_document(state, source_uri.as_str());
469    }
470}
471
472pub(crate) fn refresh_source_indexes_for_resolution_settings_change(state: &mut LspShellState) {
473    refresh_workspace_resolution_inputs(state);
474    let source_uris = state
475        .documents
476        .values()
477        .filter(|document| !is_style_document_uri(document.uri.as_str()))
478        .map(|document| document.uri.clone())
479        .collect::<Vec<_>>();
480    for source_uri in source_uris {
481        let resolution_inputs = state
482            .document(source_uri.as_str())
483            .map(|document| {
484                resolution_inputs_for_workspace_uri(state, document.workspace_folder_uri.as_deref())
485            })
486            .unwrap_or_else(|| resolution_inputs_for_workspace_uri(state, None));
487        if let Some(document) = state.document_mut(source_uri.as_str()) {
488            refresh_document_reusable_indexes(document, &resolution_inputs);
489        }
490        refresh_source_type_fact_candidates_for_document(state, source_uri.as_str());
491    }
492}
493
494pub(crate) fn is_resolution_config_document_uri(uri: &str) -> bool {
495    let Some(path) = file_uri_to_path(uri) else {
496        return false;
497    };
498    let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
499        return false;
500    };
501    file_name == "package.json"
502        || file_name == "jsconfig.json"
503        || (file_name.starts_with("tsconfig") && file_name.ends_with(".json"))
504        || matches!(
505            file_name,
506            "vite.config.ts"
507                | "vite.config.mts"
508                | "vite.config.cts"
509                | "vite.config.js"
510                | "vite.config.mjs"
511                | "vite.config.cjs"
512                | "webpack.config.ts"
513                | "webpack.config.mts"
514                | "webpack.config.cts"
515                | "webpack.config.js"
516                | "webpack.config.mjs"
517                | "webpack.config.cjs"
518        )
519}
520
521pub(crate) fn ensure_style_document_loaded_from_disk(state: &mut LspShellState, uri: &str) -> bool {
522    if state.contains_document_uri(uri) {
523        return true;
524    }
525    reload_indexed_style_document_from_disk(state, uri)
526}
527
528fn reload_indexed_style_document_from_disk(state: &mut LspShellState, uri: &str) -> bool {
529    let Some(path) = file_uri_to_path(uri) else {
530        return false;
531    };
532    let Ok(text) = fs::read_to_string(path) else {
533        return false;
534    };
535    let workspace_folder_uri = resolve_workspace_folder_uri(state, uri);
536    let resolution_inputs =
537        resolution_inputs_for_workspace_uri(state, workspace_folder_uri.as_deref());
538    state.insert_document(
539        uri,
540        lsp_text_document_state(
541            uri.to_string(),
542            workspace_folder_uri,
543            StyleLanguage::from_module_path(uri)
544                .map(style_language_label)
545                .unwrap_or("unknown")
546                .to_string(),
547            0,
548            text,
549            &resolution_inputs,
550        ),
551    );
552    true
553}
554
555fn refresh_source_type_fact_candidates_for_referencing_documents(
556    state: &mut LspShellState,
557    style_uri: &str,
558) {
559    let source_uris = state
560        .documents
561        .values()
562        .filter(|document| !is_style_document_uri(document.uri.as_str()))
563        .filter(|document| document_references_style_uri(document, style_uri))
564        .map(|document| document.uri.clone())
565        .collect::<Vec<_>>();
566    for source_uri in source_uris {
567        refresh_source_type_fact_candidates_for_document(state, source_uri.as_str());
568    }
569}
570
571fn document_references_style_uri(document: &LspTextDocumentState, style_uri: &str) -> bool {
572    document
573        .source_syntax_index
574        .selector_references
575        .iter()
576        .any(|reference| reference.target_style_uri.as_deref() == Some(style_uri))
577        || document
578            .source_syntax_index
579            .type_fact_targets
580            .iter()
581            .any(|target| target.target_style_uri.as_deref() == Some(style_uri))
582}
583
584fn insert_workspace_folder(state: &mut LspShellState, folder: &Value) {
585    let Some(uri) = folder.get("uri").and_then(Value::as_str) else {
586        return;
587    };
588    state.workspace_runtime_registry.insert(
589        uri.to_string(),
590        folder
591            .get("name")
592            .and_then(Value::as_str)
593            .unwrap_or(uri)
594            .to_string(),
595    );
596}
597
598fn refresh_document_workspace_owners(state: &mut LspShellState) {
599    let workspace_runtime_registry = state.workspace_runtime_registry.clone();
600    for document in state.documents.values_mut() {
601        document.workspace_folder_uri =
602            workspace_runtime_registry.resolve_owner_uri(document.uri.as_str());
603    }
604}
605
606fn resolve_workspace_folder_uri(state: &LspShellState, document_uri: &str) -> Option<String> {
607    state
608        .workspace_runtime_registry
609        .resolve_owner_uri(document_uri)
610}
611
612fn summarize_style_document(uri: &str, text: Option<&str>) -> Option<LspStyleDocumentSummary> {
613    let text = text?;
614    let summary = summarize_omena_query_style_document(uri, text)?;
615    Some(LspStyleDocumentSummary {
616        language: summary.language,
617        selector_names: summary.selector_names,
618        custom_property_decl_names: summary.custom_property_decl_names,
619        custom_property_ref_names: summary.custom_property_ref_names,
620        sass_module_use_sources: summary.sass_module_use_sources,
621        sass_module_forward_sources: summary.sass_module_forward_sources,
622        diagnostic_count: summary.diagnostic_count,
623    })
624}
625
626pub fn resolve_style_hover_candidates(
627    state: &LspShellState,
628    params: Option<&Value>,
629) -> LspStyleHoverCandidatesResult {
630    let document_uri = document_uri_from_params(params);
631    let query_position = lsp_position_from_params(params);
632    let Some(document) = state.document(&document_uri) else {
633        return empty_style_hover_candidates_result(document_uri, None, query_position);
634    };
635
636    let Some((language, mut candidates)) = style_hover_candidates_for_document(document) else {
637        return empty_style_hover_candidates_result(
638            document_uri,
639            document.workspace_folder_uri.clone(),
640            query_position,
641        );
642    };
643
644    if let Some(position) = query_position {
645        candidates.retain(|candidate| parser_range_contains_position(&candidate.range, position));
646    }
647
648    LspStyleHoverCandidatesResult {
649        schema_version: "0",
650        product: "omena-lsp-server.style-hover-candidates",
651        document_uri,
652        workspace_folder_uri: document.workspace_folder_uri.clone(),
653        language: Some(language),
654        query_position,
655        candidate_count: candidates.len(),
656        candidates,
657    }
658}
659
660fn style_hover_candidates_for_document(
661    document: &LspTextDocumentState,
662) -> Option<(&'static str, Vec<LspStyleHoverCandidate>)> {
663    let summary = document.style_summary.as_ref()?;
664    Some((summary.language, document.style_candidates.clone()))
665}
666
667fn style_text_for_uri(state: &LspShellState, uri: &str) -> Option<String> {
668    state
669        .document(uri)
670        .map(|document| document.text.clone())
671        .or_else(|| fs::read_to_string(file_uri_to_path(uri)?).ok())
672}
673
674fn style_hover_candidates_for_uri(
675    state: &LspShellState,
676    uri: &str,
677) -> Option<(&'static str, Vec<LspStyleHoverCandidate>)> {
678    if let Some(document) = state.document(uri) {
679        return style_hover_candidates_for_document(document);
680    }
681    let text = style_text_for_uri(state, uri)?;
682    collect_style_hover_candidates(uri, text.as_str())
683}
684
685fn resolve_lsp_definition(state: &LspShellState, params: Option<&Value>) -> Value {
686    let document_uri = document_uri_from_params(params);
687    let Some(position) = lsp_position_from_params(params) else {
688        return Value::Null;
689    };
690    let Some(document) = state.document(&document_uri) else {
691        return Value::Null;
692    };
693    if !is_style_document_uri(document.uri.as_str()) {
694        return resolve_source_lsp_definition(state, document, position);
695    }
696
697    let Some((_, candidates)) = style_hover_candidates_for_document(document) else {
698        return Value::Null;
699    };
700    let Some(candidate) = candidates
701        .iter()
702        .find(|candidate| parser_range_contains_position(&candidate.range, position))
703    else {
704        return Value::Null;
705    };
706    if is_sass_symbol_reference_kind(candidate.kind) {
707        let definitions = sass_symbol_definitions_for_candidate(state, document, candidate);
708        if definitions.is_empty() {
709            return Value::Null;
710        }
711        return json!(
712            definitions
713                .into_iter()
714                .map(|(uri, definition)| json!({ "uri": uri, "range": definition.range }))
715                .collect::<Vec<_>>()
716        );
717    }
718    let target = if candidate.kind == "customPropertyReference" {
719        candidates
720            .iter()
721            .find(|target| {
722                target.kind == "customPropertyDeclaration" && target.name == candidate.name
723            })
724            .unwrap_or(candidate)
725    } else {
726        candidate
727    };
728
729    json!([
730        {
731            "uri": document.uri.as_str(),
732            "range": target.range,
733        },
734    ])
735}
736
737fn resolve_lsp_references(state: &LspShellState, params: Option<&Value>) -> Value {
738    let document_uri = document_uri_from_params(params);
739    let Some(position) = lsp_position_from_params(params) else {
740        return Value::Null;
741    };
742    let Some(document) = state.document(&document_uri) else {
743        return Value::Null;
744    };
745    if !is_style_document_uri(document.uri.as_str()) {
746        return resolve_source_lsp_references(state, document, position, params);
747    }
748
749    let Some((_, candidates)) = style_hover_candidates_for_document(document) else {
750        return Value::Null;
751    };
752    let Some(candidate) = candidates
753        .iter()
754        .find(|candidate| parser_range_contains_position(&candidate.range, position))
755    else {
756        return Value::Null;
757    };
758    let include_declaration = include_declaration_from_params(params);
759    let mut locations: Vec<Value> = if candidate.kind.starts_with("customProperty") {
760        candidates
761            .iter()
762            .filter(|target| {
763                target.name == candidate.name
764                    && (target.kind == "customPropertyReference"
765                        || (include_declaration && target.kind == "customPropertyDeclaration"))
766            })
767            .map(|target| json!({ "uri": document.uri.as_str(), "range": target.range }))
768            .collect()
769    } else if is_sass_symbol_candidate_kind(candidate.kind) {
770        let mut locations = Vec::new();
771        if include_declaration {
772            locations.extend(
773                sass_symbol_definitions_for_candidate(state, document, candidate)
774                    .into_iter()
775                    .map(|(uri, definition)| json!({ "uri": uri, "range": definition.range })),
776            );
777        }
778        locations.extend(
779            candidates
780                .iter()
781                .filter(|target| sass_symbol_reference_matches(candidate, target))
782                .map(|target| json!({ "uri": document.uri.as_str(), "range": target.range })),
783        );
784        locations
785    } else if candidate.kind == "selector" {
786        let mut locations = if include_declaration {
787            vec![json!({ "uri": document.uri.as_str(), "range": candidate.range })]
788        } else {
789            Vec::new()
790        };
791        locations.extend(selector_reference_locations_from_open_documents(
792            state,
793            candidate.name.as_str(),
794            document.workspace_folder_uri.as_deref(),
795            Some(document.uri.as_str()),
796        ));
797        locations
798    } else if include_declaration {
799        vec![json!({ "uri": document.uri.as_str(), "range": candidate.range })]
800    } else {
801        Vec::new()
802    };
803
804    locations.sort_by_key(|location| {
805        let line = location
806            .pointer("/range/start/line")
807            .and_then(Value::as_u64)
808            .unwrap_or_default();
809        let character = location
810            .pointer("/range/start/character")
811            .and_then(Value::as_u64)
812            .unwrap_or_default();
813        (line, character)
814    });
815    json!(locations)
816}
817
818fn resolve_lsp_completion(state: &LspShellState, params: Option<&Value>) -> Value {
819    let document_uri = document_uri_from_params(params);
820    let Some(document) = state.document(&document_uri) else {
821        return Value::Null;
822    };
823    if !is_style_document_uri(document.uri.as_str()) {
824        return resolve_source_lsp_completion(state, document, params);
825    }
826
827    let Some(position) = lsp_position_from_params(params) else {
828        return Value::Null;
829    };
830    let Some((_, candidates)) = style_hover_candidates_for_document(document) else {
831        return Value::Null;
832    };
833
834    let query_candidates = candidates
835        .iter()
836        .map(query_style_hover_candidate_from_lsp)
837        .collect::<Vec<_>>();
838    let completion = summarize_omena_query_style_completion_at_position(
839        document.uri.as_str(),
840        document.text.as_str(),
841        position,
842        query_candidates.as_slice(),
843    );
844    let items: Vec<Value> = completion
845        .items
846        .into_iter()
847        .map(|item| lsp_completion_item_from_query(completion.file_kind, item))
848        .collect();
849
850    json!({
851        "isIncomplete": false,
852        "items": items,
853    })
854}
855
856fn lsp_completion_item_from_query(file_kind: &str, item: OmenaQueryCompletionItemV0) -> Value {
857    let kind = match (file_kind, item.item_kind) {
858        ("style", "cssModuleSelector") => 7,
859        (_, "cssModuleSelector") | (_, "cssCustomProperty") => 10,
860        _ => 1,
861    };
862    json!({
863        "label": item.label,
864        "kind": kind,
865        "sortText": item.sort_text,
866        "detail": item.detail,
867        "insertText": item.insert_text,
868        "data": {
869            "source": item.source,
870            "rankingSource": item.ranking_source,
871        },
872    })
873}
874
875fn resolve_style_diagnostics(state: &LspShellState, params: Option<&Value>) -> Value {
876    let document_uri = document_uri_from_params(params);
877    resolve_style_diagnostics_for_uri(state, document_uri.as_str())
878}
879
880fn resolve_source_diagnostics(state: &LspShellState, params: Option<&Value>) -> Value {
881    let document_uri = document_uri_from_params(params);
882    resolve_source_diagnostics_for_uri(state, document_uri.as_str())
883}
884
885fn resolve_cascade_at_position(state: &LspShellState, params: Option<&Value>) -> Value {
886    let document_uri = document_uri_from_params(params);
887    let Some(position) = lsp_position_from_params(params) else {
888        return Value::Null;
889    };
890    let Some(document) = state.document(&document_uri) else {
891        return Value::Null;
892    };
893    if !is_style_document_uri(document.uri.as_str()) {
894        return Value::Null;
895    }
896    let Some(engine_input) = query_engine_input_from_params(params) else {
897        return Value::Null;
898    };
899
900    let include_categorical_evidence = params
901        .and_then(|value| value.get("context"))
902        .and_then(|value| value.get("includeCategoricalEvidence"))
903        .and_then(Value::as_bool)
904        .unwrap_or(false);
905
906    read_omena_query_cascade_at_position_with_categorical_evidence(
907        document.uri.as_str(),
908        document.text.as_str(),
909        &engine_input,
910        position,
911        include_categorical_evidence,
912    )
913    .map(|result| json!(result))
914    .unwrap_or(Value::Null)
915}
916
917fn resolve_style_context_index(state: &LspShellState, params: Option<&Value>) -> Value {
918    let document_uri = document_uri_from_params(params);
919    let Some(document) = state.document(&document_uri) else {
920        return Value::Null;
921    };
922    if !is_style_document_uri(document.uri.as_str()) {
923        return Value::Null;
924    }
925    let Some(engine_input) = query_engine_input_from_params(params) else {
926        return Value::Null;
927    };
928
929    read_omena_query_style_context_index(
930        document.uri.as_str(),
931        document.text.as_str(),
932        &engine_input,
933    )
934    .map(|result| json!(result))
935    .unwrap_or(Value::Null)
936}
937
938fn query_engine_input_from_params(params: Option<&Value>) -> Option<OmenaQueryEngineInputV2> {
939    if let Some(engine_input) = params.and_then(|value| value.get("engineInput")) {
940        return serde_json::from_value(engine_input.clone()).ok();
941    }
942
943    serde_json::from_value(json!({
944        "version": "2",
945        "sources": [],
946        "styles": [],
947        "typeFacts": [],
948    }))
949    .ok()
950}
951
952fn resolve_document_diagnostics_for_uri(state: &LspShellState, document_uri: &str) -> Value {
953    if is_style_document_uri(document_uri) {
954        resolve_style_diagnostics_for_uri(state, document_uri)
955    } else {
956        resolve_source_diagnostics_for_uri(state, document_uri)
957    }
958}
959
960fn resolve_style_diagnostics_for_uri(state: &LspShellState, document_uri: &str) -> Value {
961    let Some(document) = state.document(document_uri) else {
962        return json!([]);
963    };
964    let Some((_, candidates)) = style_hover_candidates_for_document(document) else {
965        return json!([]);
966    };
967
968    let query_candidates = candidates
969        .iter()
970        .map(query_style_hover_candidate_from_lsp)
971        .collect::<Vec<_>>();
972    let style_sources = style_sources_from_open_documents(
973        state,
974        document.workspace_folder_uri.as_deref(),
975        Some(document.uri.as_str()),
976    );
977    let source_documents =
978        source_documents_from_open_documents(state, document.workspace_folder_uri.as_deref());
979    // #35: drive the workspace path in `Sif` mode whenever external SIF artifacts are available
980    // (sourced from the lock/bridge per #32/#33). That branch is what classifies the external
981    // boundary lattice and parses the `@omena-strict:` sigil; with no SIFs present we fall back to
982    // `Ignored`, which is byte-for-byte the legacy behaviour.
983    let external_sifs = state.resolution.external_sifs.as_slice();
984    let external_mode = if external_sifs.is_empty() {
985        OmenaQueryExternalModuleModeV0::Ignored
986    } else {
987        OmenaQueryExternalModuleModeV0::Sif
988    };
989    // RFC-0007-J (#50): pass the workspace's tsconfig/bundler path mappings so the unused-selector
990    // usage collector resolves alias style imports (`@/styles/...`) the same way the reference/goto
991    // path does — otherwise an alias import dims every selector as `unusedSelector`.
992    let resolution_inputs =
993        resolution_inputs_for_workspace_uri(state, document.workspace_folder_uri.as_deref());
994    let diagnostics_summary =
995        summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode_and_sifs_and_resolution_inputs(
996            document.uri.as_str(),
997            style_sources.as_slice(),
998            source_documents.as_slice(),
999            state.resolution.package_manifests.as_slice(),
1000            None,
1001            external_mode,
1002            external_sifs,
1003            &resolution_inputs,
1004        )
1005        .unwrap_or_else(|| {
1006            summarize_omena_query_style_diagnostics_for_file(
1007                document.uri.as_str(),
1008                document.text.as_str(),
1009                query_candidates.as_slice(),
1010            )
1011        });
1012    let diagnostics = diagnostics_summary
1013        .diagnostics
1014        .into_iter()
1015        .map(|diagnostic| {
1016            let tags = diagnostic.tags;
1017            let query_severity = diagnostic.severity;
1018            let mut data = serde_json::Map::new();
1019            data.insert("querySeverity".to_string(), json!(query_severity));
1020            data.insert("provenance".to_string(), json!(diagnostic.provenance));
1021            if let Some(create_custom_property) = diagnostic.create_custom_property {
1022                data.insert(
1023                    "createCustomProperty".to_string(),
1024                    json!(create_custom_property),
1025                );
1026            }
1027
1028            let mut lsp_diagnostic = json!({
1029                "range": diagnostic.range,
1030                "severity": lsp_diagnostic_severity(query_severity, state.diagnostics.severity),
1031                "code": diagnostic.code,
1032                "source": "omena-css",
1033                "message": diagnostic.message,
1034                "data": Value::Object(data),
1035            });
1036            if !tags.is_empty() {
1037                lsp_diagnostic["tags"] = json!(tags);
1038            }
1039            lsp_diagnostic
1040        })
1041        .collect::<Vec<_>>();
1042
1043    json!(diagnostics)
1044}
1045
1046fn resolve_source_diagnostics_for_uri(state: &LspShellState, document_uri: &str) -> Value {
1047    let Some(document) = state.document(document_uri) else {
1048        return json!([]);
1049    };
1050    if is_style_document_uri(document.uri.as_str()) {
1051        return json!([]);
1052    }
1053
1054    let candidates = resolve_source_provider_candidates(state, document)
1055        .unresolved
1056        .into_iter()
1057        .filter(|candidate| candidate.kind == "sourceSelectorReference")
1058        .filter_map(|candidate| {
1059            let (target_style_uri, target_style_document) = source_selector_diagnostic_target(
1060                state,
1061                &candidate,
1062                document.workspace_folder_uri.as_deref(),
1063            )?;
1064            Some(OmenaQuerySourceMissingSelectorDiagnosticCandidateV0 {
1065                target_style_uri,
1066                target_style_source: target_style_document.text.clone(),
1067                selector_name: candidate.name,
1068                source_reference_range: candidate.range,
1069            })
1070        })
1071        .collect::<Vec<_>>();
1072    let diagnostics: Vec<Value> = summarize_omena_query_source_diagnostics_for_file(
1073        document.uri.as_str(),
1074        candidates.as_slice(),
1075    )
1076    .diagnostics
1077    .into_iter()
1078    .filter_map(|diagnostic| {
1079        let query_severity = diagnostic.severity;
1080        let create_selector = diagnostic.create_selector?;
1081        let mut data = serde_json::Map::new();
1082        data.insert("querySeverity".to_string(), json!(query_severity));
1083        data.insert("provenance".to_string(), json!(diagnostic.provenance));
1084        data.insert("createSelector".to_string(), json!(create_selector));
1085
1086        Some(json!({
1087            "range": diagnostic.range,
1088            "severity": lsp_diagnostic_severity(query_severity, state.diagnostics.severity),
1089            "code": diagnostic.code,
1090            "source": "omena-css",
1091            "message": diagnostic.message,
1092            "data": Value::Object(data),
1093        }))
1094    })
1095    .collect();
1096
1097    json!(diagnostics)
1098}
1099
1100fn lsp_diagnostic_severity(query_severity: &str, configured_severity: u8) -> u8 {
1101    if configured_severity != 2 {
1102        return configured_severity;
1103    }
1104    match query_severity {
1105        "error" => 1,
1106        "warning" => 2,
1107        "information" => 3,
1108        "hint" => 4,
1109        _ => configured_severity,
1110    }
1111}
1112
1113fn source_selector_diagnostic_target<'a>(
1114    state: &'a LspShellState,
1115    candidate: &LspStyleHoverCandidate,
1116    workspace_folder_uri: Option<&str>,
1117) -> Option<(String, &'a LspTextDocumentState)> {
1118    if let Some(target_style_uri) = candidate.target_style_uri.as_deref() {
1119        let target_document = state.document(target_style_uri)?;
1120        if !document_has_style_index(target_document)
1121            || !workspace_folder_compatible(workspace_folder_uri, target_document)
1122        {
1123            return None;
1124        }
1125        return Some((target_document.uri.clone(), target_document));
1126    }
1127
1128    first_style_document_for_workspace(state, workspace_folder_uri)
1129}
1130
1131fn resolve_lsp_code_actions(state: &LspShellState, params: Option<&Value>) -> Value {
1132    let diagnostics = params
1133        .and_then(|value| value.get("context"))
1134        .and_then(|value| value.get("diagnostics"))
1135        .and_then(Value::as_array)
1136        .map(Vec::as_slice)
1137        .unwrap_or(&[]);
1138
1139    let mut actions: Vec<Value> = diagnostics
1140        .iter()
1141        .enumerate()
1142        .filter_map(|(index, diagnostic)| {
1143            let payload = diagnostic
1144                .pointer("/data/createCustomProperty")
1145                .and_then(Value::as_object)?;
1146            let uri = payload.get("uri").and_then(Value::as_str)?;
1147            let range = payload.get("range")?;
1148            let new_text = payload.get("newText").and_then(Value::as_str)?;
1149            let property_name = payload.get("propertyName").and_then(Value::as_str)?;
1150            let mut changes = serde_json::Map::new();
1151            changes.insert(
1152                uri.to_string(),
1153                json!([
1154                    {
1155                        "range": range,
1156                        "newText": new_text,
1157                    },
1158                ]),
1159            );
1160
1161            Some(json!({
1162                "title": format!("Add '{}' to {}", property_name, file_label_from_uri(uri)),
1163                "kind": "quickfix",
1164                "diagnostics": [diagnostic],
1165                "edit": {
1166                    "changes": Value::Object(changes),
1167                },
1168                "data": {
1169                    "source": "omenaQueryStyleDiagnosticsForFile",
1170                    "diagnosticIndex": index,
1171                },
1172            }))
1173        })
1174        .chain(diagnostics.iter().enumerate().filter_map(|(index, diagnostic)| {
1175            let payload = diagnostic
1176                .pointer("/data/createSelector")
1177                .and_then(Value::as_object)?;
1178            let uri = payload.get("uri").and_then(Value::as_str)?;
1179            let range = payload.get("range")?;
1180            let new_text = payload.get("newText").and_then(Value::as_str)?;
1181            let selector_name = payload.get("selectorName").and_then(Value::as_str)?;
1182            let mut changes = serde_json::Map::new();
1183            changes.insert(
1184                uri.to_string(),
1185                json!([
1186                    {
1187                        "range": range,
1188                        "newText": new_text,
1189                    },
1190                ]),
1191            );
1192
1193            Some(json!({
1194                "title": format!("Add '.{}' to {}", selector_name, file_label_from_uri(uri)),
1195                "kind": "quickfix",
1196                "diagnostics": [diagnostic],
1197                "edit": {
1198                    "changes": Value::Object(changes),
1199                },
1200                "data": {
1201                    "source": "omenaQuerySourceSyntaxIndex",
1202                    "diagnosticIndex": index,
1203                },
1204            }))
1205        }))
1206        .collect();
1207
1208    if diagnostics.is_empty() {
1209        actions.extend(resolve_lsp_refactor_code_actions(state, params));
1210    }
1211
1212    if actions.is_empty() {
1213        Value::Null
1214    } else {
1215        json!(actions)
1216    }
1217}
1218
1219fn resolve_lsp_refactor_code_actions(state: &LspShellState, params: Option<&Value>) -> Vec<Value> {
1220    let document_uri = document_uri_from_params(params);
1221    let Some(document) = state.document(document_uri.as_str()) else {
1222        return Vec::new();
1223    };
1224    if !is_style_document_uri(document.uri.as_str()) {
1225        return Vec::new();
1226    }
1227    let Some(range) = params
1228        .and_then(|value| value.get("range"))
1229        .and_then(lsp_range_from_value)
1230    else {
1231        return Vec::new();
1232    };
1233
1234    let style_sources = style_sources_from_open_documents(
1235        state,
1236        document.workspace_folder_uri.as_deref(),
1237        Some(document.uri.as_str()),
1238    );
1239    let inline_actions = summarize_omena_query_style_inline_code_actions(
1240        document.uri.as_str(),
1241        style_sources.as_slice(),
1242        range,
1243        &[],
1244    )
1245    .actions;
1246    if !inline_actions.is_empty() {
1247        return render_omena_query_lsp_code_actions(inline_actions);
1248    }
1249
1250    let extract_actions = summarize_omena_query_style_extract_code_actions(
1251        document.uri.as_str(),
1252        document.text.as_str(),
1253        range,
1254    )
1255    .actions;
1256    render_omena_query_lsp_code_actions(extract_actions)
1257}
1258
1259fn style_sources_from_open_documents(
1260    state: &LspShellState,
1261    workspace_folder_uri: Option<&str>,
1262    required_document_uri: Option<&str>,
1263) -> Vec<OmenaQueryStyleSourceInputV0> {
1264    let mut sources = state
1265        .documents
1266        .values()
1267        .filter(|document| {
1268            is_style_document_uri(document.uri.as_str())
1269                && workspace_folder_compatible(workspace_folder_uri, document)
1270        })
1271        .map(|document| OmenaQueryStyleSourceInputV0 {
1272            style_path: document.uri.clone(),
1273            style_source: document.text.clone(),
1274        })
1275        .collect::<Vec<_>>();
1276    if let Some(required_document_uri) = required_document_uri
1277        && !sources
1278            .iter()
1279            .any(|source| source.style_path == required_document_uri)
1280        && let Some(document) = state.document(required_document_uri)
1281    {
1282        sources.push(OmenaQueryStyleSourceInputV0 {
1283            style_path: document.uri.clone(),
1284            style_source: document.text.clone(),
1285        });
1286    }
1287    sources
1288}
1289
1290fn source_documents_from_open_documents(
1291    state: &LspShellState,
1292    workspace_folder_uri: Option<&str>,
1293) -> Vec<OmenaQuerySourceDocumentInputV0> {
1294    state
1295        .documents
1296        .values()
1297        .filter(|document| {
1298            !is_style_document_uri(document.uri.as_str())
1299                && workspace_folder_compatible(workspace_folder_uri, document)
1300        })
1301        .map(|document| OmenaQuerySourceDocumentInputV0 {
1302            source_path: document.uri.clone(),
1303            source_source: document.text.clone(),
1304        })
1305        .collect()
1306}
1307
1308fn render_omena_query_lsp_code_actions(actions: Vec<OmenaQueryCodeActionV0>) -> Vec<Value> {
1309    actions
1310        .into_iter()
1311        .enumerate()
1312        .map(|(index, action)| {
1313            let mut changes_by_uri = BTreeMap::<String, Vec<Value>>::new();
1314            for edit in action.edits {
1315                changes_by_uri.entry(edit.uri).or_default().push(json!({
1316                    "range": edit.range,
1317                    "newText": edit.new_text,
1318                }));
1319            }
1320
1321            let changes = changes_by_uri
1322                .into_iter()
1323                .map(|(uri, edits)| (uri, Value::Array(edits)))
1324                .collect::<serde_json::Map<_, _>>();
1325
1326            json!({
1327                "title": action.title,
1328                "kind": action.kind,
1329                "edit": {
1330                    "changes": Value::Object(changes),
1331                },
1332                "data": {
1333                    "source": action.source,
1334                    "actionIndex": index,
1335                },
1336            })
1337        })
1338        .collect()
1339}
1340
1341fn resolve_lsp_code_lens(state: &LspShellState, params: Option<&Value>) -> Value {
1342    let document_uri = document_uri_from_params(params);
1343    let Some(document) = state.document(document_uri.as_str()) else {
1344        return Value::Null;
1345    };
1346    let Some((_, candidates)) = style_hover_candidates_for_document(document) else {
1347        return Value::Null;
1348    };
1349
1350    let mut lenses = Vec::new();
1351    let mut emitted_selectors = BTreeSet::new();
1352    let reference_locations_by_name = selector_reference_locations_by_name_from_open_documents(
1353        state,
1354        document.workspace_folder_uri.as_deref(),
1355        Some(document.uri.as_str()),
1356    );
1357    for candidate in candidates
1358        .iter()
1359        .filter(|candidate| candidate.kind == "selector")
1360    {
1361        if !emitted_selectors.insert(candidate.name.as_str()) {
1362            continue;
1363        }
1364        let locations = reference_locations_by_name
1365            .get(candidate.name.as_str())
1366            .cloned()
1367            .unwrap_or_default();
1368        if locations.is_empty() {
1369            continue;
1370        }
1371        let position = candidate.range.start;
1372        lenses.push(json!({
1373            "range": {
1374                "start": position,
1375                "end": position,
1376            },
1377            "command": {
1378                "title": reference_lens_title(locations.len()),
1379                "command": "editor.action.showReferences",
1380                "arguments": [
1381                    document.uri.as_str(),
1382                    position,
1383                    locations,
1384                ],
1385            },
1386        }));
1387    }
1388    lenses.sort_by_key(lsp_range_start_sort_key);
1389
1390    if lenses.is_empty() {
1391        Value::Null
1392    } else {
1393        json!(lenses)
1394    }
1395}
1396
1397fn selector_reference_locations_from_open_documents(
1398    state: &LspShellState,
1399    selector_name: &str,
1400    workspace_folder_uri: Option<&str>,
1401    target_style_uri: Option<&str>,
1402) -> Vec<Value> {
1403    let definitions =
1404        style_selector_definitions_from_open_documents(state, "", workspace_folder_uri)
1405            .iter()
1406            .map(|(uri, definition)| query_style_selector_definition_for_matching(uri, definition))
1407            .collect::<Vec<_>>();
1408    let query_target_style_uri = query_target_style_uri_for_matching(target_style_uri);
1409    let mut references = Vec::new();
1410    for document in state.documents.values() {
1411        if is_style_document_uri(document.uri.as_str()) {
1412            continue;
1413        }
1414        if !workspace_folder_compatible(workspace_folder_uri, document) {
1415            continue;
1416        }
1417        references.extend(
1418            collect_source_selector_reference_candidates(state, document)
1419                .iter()
1420                .map(|candidate| {
1421                    query_source_selector_reference_candidate_for_matching(document, candidate)
1422                }),
1423        );
1424    }
1425    summarize_omena_query_refs_for_class(
1426        selector_name,
1427        query_target_style_uri.as_deref(),
1428        false,
1429        definitions.as_slice(),
1430        references.as_slice(),
1431    )
1432    .locations
1433    .into_iter()
1434    .map(|location| json!({ "uri": location.uri, "range": location.range }))
1435    .collect()
1436}
1437
1438fn selector_reference_locations_by_name_from_open_documents(
1439    state: &LspShellState,
1440    workspace_folder_uri: Option<&str>,
1441    target_style_uri: Option<&str>,
1442) -> BTreeMap<String, Vec<Value>> {
1443    let mut locations_by_name: BTreeMap<String, Vec<Value>> = BTreeMap::new();
1444    let definitions =
1445        style_selector_definitions_from_open_documents(state, "", workspace_folder_uri);
1446    for document in state.documents.values() {
1447        if is_style_document_uri(document.uri.as_str()) {
1448            continue;
1449        }
1450        if !workspace_folder_compatible(workspace_folder_uri, document) {
1451            continue;
1452        }
1453        for candidate in collect_source_selector_reference_candidates(state, document) {
1454            if !source_candidate_matches_target_style(&candidate, target_style_uri) {
1455                continue;
1456            }
1457            for selector_name in source_candidate_selector_names(
1458                &candidate,
1459                definitions.as_slice(),
1460                target_style_uri,
1461            ) {
1462                locations_by_name
1463                    .entry(selector_name)
1464                    .or_default()
1465                    .push(json!({
1466                        "uri": document.uri.as_str(),
1467                        "range": candidate.range,
1468                    }));
1469            }
1470        }
1471    }
1472    for locations in locations_by_name.values_mut() {
1473        locations.sort_by_key(location_sort_key);
1474        locations
1475            .dedup_by(|left, right| location_identity_key(left) == location_identity_key(right));
1476    }
1477    locations_by_name
1478}
1479
1480fn source_candidate_selector_names(
1481    candidate: &LspStyleHoverCandidate,
1482    definitions: &[(String, LspStyleHoverCandidate)],
1483    target_style_uri: Option<&str>,
1484) -> Vec<String> {
1485    let query_definitions = definitions
1486        .iter()
1487        .map(|(uri, definition)| query_style_selector_definition_for_matching(uri, definition))
1488        .collect::<Vec<_>>();
1489    let query_target_style_uri = query_target_style_uri_for_matching(target_style_uri);
1490    resolve_omena_query_source_candidate_selector_names(
1491        &query_source_selector_candidate_for_matching(candidate),
1492        query_definitions.as_slice(),
1493        query_target_style_uri.as_deref(),
1494    )
1495}
1496
1497fn source_candidate_matches_target_style(
1498    candidate: &LspStyleHoverCandidate,
1499    target_style_uri: Option<&str>,
1500) -> bool {
1501    target_style_uri.is_none_or(|target_uri| {
1502        candidate
1503            .target_style_uri
1504            .as_deref()
1505            .is_none_or(|candidate_target_uri| {
1506                file_uri_equivalent(candidate_target_uri, target_uri)
1507            })
1508    })
1509}
1510
1511fn sass_symbol_definitions_for_candidate(
1512    state: &LspShellState,
1513    document: &LspTextDocumentState,
1514    candidate: &LspStyleHoverCandidate,
1515) -> Vec<(String, LspStyleHoverCandidate)> {
1516    let Some(symbol_kind) = sass_symbol_kind_from_candidate_kind(candidate.kind) else {
1517        return Vec::new();
1518    };
1519    if is_sass_symbol_declaration_kind(candidate.kind) {
1520        return vec![(document.uri.clone(), candidate.clone())];
1521    }
1522
1523    let mut definitions = if candidate.namespace.is_none() {
1524        sass_symbol_declarations_in_document(document, symbol_kind, candidate)
1525    } else {
1526        Vec::new()
1527    };
1528    if candidate.namespace.is_none() && !definitions.is_empty() {
1529        return definitions;
1530    }
1531
1532    for target_uri in sass_module_target_uris_for_candidate(state, document, candidate) {
1533        definitions.extend(sass_symbol_declarations_for_uri(
1534            state,
1535            target_uri.as_str(),
1536            symbol_kind,
1537            candidate,
1538        ));
1539    }
1540    definitions.sort_by_key(|(uri, target)| {
1541        (
1542            uri.clone(),
1543            target.range.start.line,
1544            target.range.start.character,
1545        )
1546    });
1547    definitions.dedup_by(|left, right| {
1548        left.0 == right.0
1549            && left.1.kind == right.1.kind
1550            && left.1.name == right.1.name
1551            && left.1.range == right.1.range
1552    });
1553    definitions
1554}
1555
1556fn sass_symbol_declarations_for_uri(
1557    state: &LspShellState,
1558    target_uri: &str,
1559    symbol_kind: &str,
1560    candidate: &LspStyleHoverCandidate,
1561) -> Vec<(String, LspStyleHoverCandidate)> {
1562    if let Some(target_document) = state.document(target_uri) {
1563        return sass_symbol_declarations_with_forwards(
1564            state,
1565            target_document,
1566            symbol_kind,
1567            candidate,
1568            &mut BTreeSet::new(),
1569        );
1570    }
1571    let Some((_, candidates)) = style_hover_candidates_for_uri(state, target_uri) else {
1572        return Vec::new();
1573    };
1574    let query_candidates = candidates
1575        .iter()
1576        .map(query_style_hover_candidate_from_lsp)
1577        .collect::<Vec<_>>();
1578    resolve_omena_query_sass_symbol_declarations(
1579        query_candidates.as_slice(),
1580        symbol_kind,
1581        candidate.name.as_str(),
1582    )
1583    .into_iter()
1584    .map(lsp_style_hover_candidate_from_query)
1585    .map(|target| (target_uri.to_string(), target))
1586    .collect()
1587}
1588
1589fn sass_symbol_declarations_in_document(
1590    document: &LspTextDocumentState,
1591    symbol_kind: &str,
1592    candidate: &LspStyleHoverCandidate,
1593) -> Vec<(String, LspStyleHoverCandidate)> {
1594    let query_candidates = document
1595        .style_candidates
1596        .iter()
1597        .map(query_style_hover_candidate_from_lsp)
1598        .collect::<Vec<_>>();
1599    resolve_omena_query_sass_symbol_declarations(
1600        query_candidates.as_slice(),
1601        symbol_kind,
1602        candidate.name.as_str(),
1603    )
1604    .into_iter()
1605    .map(lsp_style_hover_candidate_from_query)
1606    .map(|target| (document.uri.clone(), target))
1607    .collect()
1608}
1609
1610fn sass_module_target_uris_for_candidate(
1611    state: &LspShellState,
1612    document: &LspTextDocumentState,
1613    candidate: &LspStyleHoverCandidate,
1614) -> Vec<String> {
1615    let Some(sources) =
1616        summarize_omena_query_sass_module_sources(document.uri.as_str(), document.text.as_str())
1617    else {
1618        return Vec::new();
1619    };
1620    let mut uris = Vec::new();
1621    for source in resolve_omena_query_sass_module_use_sources_for_candidate(
1622        &sources,
1623        candidate.namespace.as_deref(),
1624    ) {
1625        if let Some(uri) = resolve_lsp_style_uri_for_specifier(state, document, source.as_str()) {
1626            uris.push(uri);
1627        }
1628    }
1629    for forward_source in resolve_omena_query_sass_forward_sources(&sources) {
1630        if let Some(uri) =
1631            resolve_lsp_style_uri_for_specifier(state, document, forward_source.as_str())
1632        {
1633            uris.push(uri.clone());
1634            if let Some(target_document) = state.document(uri.as_str()) {
1635                uris.extend(sass_forward_module_target_uris(
1636                    state,
1637                    target_document,
1638                    &mut BTreeSet::new(),
1639                ));
1640            }
1641        }
1642    }
1643    uris.sort();
1644    uris.dedup();
1645    uris
1646}
1647
1648fn sass_symbol_declarations_with_forwards(
1649    state: &LspShellState,
1650    document: &LspTextDocumentState,
1651    symbol_kind: &str,
1652    candidate: &LspStyleHoverCandidate,
1653    visited: &mut BTreeSet<String>,
1654) -> Vec<(String, LspStyleHoverCandidate)> {
1655    if !visited.insert(document.uri.clone()) {
1656        return Vec::new();
1657    }
1658    let mut definitions = sass_symbol_declarations_in_document(document, symbol_kind, candidate);
1659    let Some(sources) =
1660        summarize_omena_query_sass_module_sources(document.uri.as_str(), document.text.as_str())
1661    else {
1662        return definitions;
1663    };
1664    for forward_source in resolve_omena_query_sass_forward_sources(&sources) {
1665        let Some(uri) =
1666            resolve_lsp_style_uri_for_specifier(state, document, forward_source.as_str())
1667        else {
1668            continue;
1669        };
1670        let Some(target_document) = state.document(uri.as_str()) else {
1671            continue;
1672        };
1673        definitions.extend(sass_symbol_declarations_with_forwards(
1674            state,
1675            target_document,
1676            symbol_kind,
1677            candidate,
1678            visited,
1679        ));
1680    }
1681    definitions
1682}
1683
1684fn sass_forward_module_target_uris(
1685    state: &LspShellState,
1686    document: &LspTextDocumentState,
1687    visited: &mut BTreeSet<String>,
1688) -> Vec<String> {
1689    if !visited.insert(document.uri.clone()) {
1690        return Vec::new();
1691    }
1692    let Some(sources) =
1693        summarize_omena_query_sass_module_sources(document.uri.as_str(), document.text.as_str())
1694    else {
1695        return Vec::new();
1696    };
1697    let mut uris = Vec::new();
1698    for forward_source in resolve_omena_query_sass_forward_sources(&sources) {
1699        if let Some(uri) =
1700            resolve_lsp_style_uri_for_specifier(state, document, forward_source.as_str())
1701        {
1702            uris.push(uri.clone());
1703        }
1704    }
1705    uris.sort();
1706    uris.dedup();
1707    uris
1708}
1709
1710fn resolve_lsp_style_uri_for_specifier(
1711    state: &LspShellState,
1712    document: &LspTextDocumentState,
1713    specifier: &str,
1714) -> Option<String> {
1715    let resolution_inputs =
1716        resolution_inputs_for_workspace_uri(state, document.workspace_folder_uri.as_deref());
1717    resolve_omena_query_style_uri_for_specifier_with_resolution_inputs(
1718        document.uri.as_str(),
1719        document.workspace_folder_uri.as_deref(),
1720        specifier,
1721        &resolution_inputs,
1722    )
1723}
1724
1725fn sass_symbol_reference_matches(
1726    candidate: &LspStyleHoverCandidate,
1727    target: &LspStyleHoverCandidate,
1728) -> bool {
1729    is_sass_symbol_reference_kind(target.kind) && sass_symbol_target_matches(candidate, target)
1730}
1731
1732fn sass_symbol_target_matches(
1733    candidate: &LspStyleHoverCandidate,
1734    target: &LspStyleHoverCandidate,
1735) -> bool {
1736    omena_query_sass_symbol_target_matches(
1737        candidate.kind,
1738        candidate.name.as_str(),
1739        candidate.namespace.as_deref(),
1740        target.kind,
1741        target.name.as_str(),
1742        target.namespace.as_deref(),
1743    )
1744}
1745
1746fn render_sass_symbol_label(candidate: &LspStyleHoverCandidate) -> String {
1747    let namespace_prefix = candidate
1748        .namespace
1749        .as_deref()
1750        .map(|namespace| format!("{namespace}."))
1751        .unwrap_or_default();
1752    match sass_symbol_kind_from_candidate_kind(candidate.kind) {
1753        Some("variable") => format!("{namespace_prefix}${}", candidate.name),
1754        Some("mixin") if is_sass_symbol_declaration_kind(candidate.kind) => {
1755            format!("@mixin {}", candidate.name)
1756        }
1757        Some("mixin") => format!("@include {namespace_prefix}{}", candidate.name),
1758        Some("function") => format!("{namespace_prefix}{}()", candidate.name),
1759        _ => candidate.name.clone(),
1760    }
1761}
1762
1763fn reference_lens_title(count: usize) -> String {
1764    if count == 1 {
1765        "1 reference".to_string()
1766    } else {
1767        format!("{count} references")
1768    }
1769}
1770
1771fn resolve_lsp_prepare_rename(state: &LspShellState, params: Option<&Value>) -> Value {
1772    if let Some((_, candidate)) = source_selector_candidate_for_params(state, params) {
1773        return json!({
1774            "range": candidate.range,
1775            "placeholder": candidate.name,
1776        });
1777    }
1778
1779    let Some((_, candidate, _)) = style_candidates_for_params(state, params) else {
1780        return Value::Null;
1781    };
1782
1783    json!({
1784        "range": candidate.range,
1785        "placeholder": rename_placeholder(&candidate),
1786    })
1787}
1788
1789fn resolve_lsp_rename(state: &LspShellState, params: Option<&Value>) -> Value {
1790    let Some(new_name) = params
1791        .and_then(|value| value.get("newName"))
1792        .and_then(Value::as_str)
1793        .filter(|value| !value.is_empty())
1794    else {
1795        return Value::Null;
1796    };
1797    if let Some((document_uri, candidate)) = source_selector_candidate_for_params(state, params) {
1798        let workspace_folder_uri = state
1799            .document(document_uri.as_str())
1800            .and_then(|document| document.workspace_folder_uri.as_deref());
1801        return resolve_selector_rename(
1802            state,
1803            workspace_folder_uri,
1804            candidate.target_style_uri.as_deref(),
1805            candidate.name.as_str(),
1806            new_name,
1807        );
1808    }
1809
1810    let Some((document_uri, candidate, candidates)) = style_candidates_for_params(state, params)
1811    else {
1812        return Value::Null;
1813    };
1814
1815    if candidate.kind == "selector" {
1816        let workspace_folder_uri = state
1817            .document(document_uri.as_str())
1818            .and_then(|document| document.workspace_folder_uri.as_deref());
1819        return resolve_selector_rename(
1820            state,
1821            workspace_folder_uri,
1822            Some(document_uri.as_str()),
1823            candidate.name.as_str(),
1824            new_name,
1825        );
1826    }
1827
1828    let replacement = match candidate.kind {
1829        "customPropertyReference" | "customPropertyDeclaration" => new_name.to_string(),
1830        _ => return Value::Null,
1831    };
1832    let mut targets: Vec<&LspStyleHoverCandidate> = candidates
1833        .iter()
1834        .filter(|target| rename_target_matches(&candidate, target))
1835        .collect();
1836    targets.sort_by_key(|target| {
1837        (
1838            target.range.start.line,
1839            target.range.start.character,
1840            target.range.end.line,
1841            target.range.end.character,
1842        )
1843    });
1844    let edits: Vec<Value> = targets
1845        .into_iter()
1846        .map(|target| {
1847            json!({
1848                "range": target.range,
1849                "newText": replacement,
1850            })
1851        })
1852        .collect();
1853    if edits.is_empty() {
1854        return Value::Null;
1855    }
1856
1857    let mut changes = serde_json::Map::new();
1858    changes.insert(document_uri, json!(edits));
1859    json!({
1860        "changes": Value::Object(changes),
1861    })
1862}
1863
1864fn style_candidates_for_params(
1865    state: &LspShellState,
1866    params: Option<&Value>,
1867) -> Option<(String, LspStyleHoverCandidate, Vec<LspStyleHoverCandidate>)> {
1868    let document_uri = document_uri_from_params(params);
1869    let position = lsp_position_from_params(params)?;
1870    let document = state.document(document_uri.as_str())?;
1871    let (_, candidates) = style_hover_candidates_for_document(document)?;
1872    let candidate = candidates
1873        .iter()
1874        .find(|candidate| parser_range_contains_position(&candidate.range, position))?
1875        .clone();
1876    Some((document_uri, candidate, candidates))
1877}
1878
1879fn rename_placeholder(candidate: &LspStyleHoverCandidate) -> &str {
1880    candidate.name.as_str()
1881}
1882
1883fn rename_target_matches(
1884    candidate: &LspStyleHoverCandidate,
1885    target: &LspStyleHoverCandidate,
1886) -> bool {
1887    match candidate.kind {
1888        "selector" => target.kind == "selector" && target.name == candidate.name,
1889        "customPropertyReference" | "customPropertyDeclaration" => {
1890            target.name == candidate.name && target.kind.starts_with("customProperty")
1891        }
1892        kind if is_sass_symbol_candidate_kind(kind) => {
1893            sass_symbol_target_matches(candidate, target)
1894        }
1895        _ => false,
1896    }
1897}
1898
1899fn resolve_lsp_hover(state: &LspShellState, params: Option<&Value>) -> Value {
1900    let document_uri = document_uri_from_params(params);
1901    if let Some(document) = state.document(document_uri.as_str())
1902        && !is_style_document_uri(document.uri.as_str())
1903    {
1904        return resolve_source_lsp_hover(state, document, params);
1905    }
1906
1907    let candidates = resolve_style_hover_candidates(state, params);
1908    let Some(candidate) = candidates.candidates.first() else {
1909        return Value::Null;
1910    };
1911    let Some(document) = state.document(document_uri.as_str()) else {
1912        return Value::Null;
1913    };
1914    if is_sass_symbol_reference_kind(candidate.kind)
1915        && let Some((target_uri, target)) =
1916            sass_symbol_definitions_for_candidate(state, document, candidate)
1917                .into_iter()
1918                .next()
1919        && let Some(target_text) = style_text_for_uri(state, target_uri.as_str())
1920    {
1921        return json!({
1922            "contents": {
1923                "kind": "markdown",
1924                "value": render_style_hover_candidate_markdown(
1925                    target_uri.as_str(),
1926                    target_text.as_str(),
1927                    &target,
1928                ),
1929            },
1930            "range": candidate.range,
1931        });
1932    }
1933
1934    json!({
1935        "contents": {
1936            "kind": "markdown",
1937            "value": render_style_hover_candidate_markdown(
1938                document.uri.as_str(),
1939                document.text.as_str(),
1940                candidate,
1941            ),
1942        },
1943        "range": candidate.range,
1944    })
1945}
1946
1947fn resolve_source_lsp_hover(
1948    state: &LspShellState,
1949    document: &LspTextDocumentState,
1950    params: Option<&Value>,
1951) -> Value {
1952    let Some(position) = lsp_position_from_params(params) else {
1953        return Value::Null;
1954    };
1955    let candidates = source_selector_candidates_at_position(state, document, position);
1956    let Some(candidate) = candidates.first() else {
1957        return Value::Null;
1958    };
1959    let definitions = style_selector_definitions_for_source_candidates(
1960        state,
1961        candidates.as_slice(),
1962        document.workspace_folder_uri.as_deref(),
1963    );
1964    let value = render_source_hover_definitions_markdown(state, definitions.as_slice())
1965        .unwrap_or_else(|| format!("**`.{}`**", candidate.name));
1966
1967    json!({
1968        "contents": {
1969            "kind": "markdown",
1970            "value": value,
1971        },
1972        "range": candidate.range,
1973    })
1974}
1975
1976fn resolve_source_lsp_definition(
1977    state: &LspShellState,
1978    document: &LspTextDocumentState,
1979    position: ParserPositionV0,
1980) -> Value {
1981    let candidates = source_selector_candidates_at_position(state, document, position);
1982    if candidates.is_empty() {
1983        return Value::Null;
1984    };
1985    let definitions = style_selector_definitions_for_source_candidates(
1986        state,
1987        candidates.as_slice(),
1988        document.workspace_folder_uri.as_deref(),
1989    );
1990    if definitions.is_empty() {
1991        return Value::Null;
1992    }
1993
1994    json!(
1995        definitions
1996            .into_iter()
1997            .map(|(uri, definition)| json!({ "uri": uri, "range": definition.range }))
1998            .collect::<Vec<_>>()
1999    )
2000}
2001
2002fn resolve_source_lsp_references(
2003    state: &LspShellState,
2004    document: &LspTextDocumentState,
2005    position: ParserPositionV0,
2006    params: Option<&Value>,
2007) -> Value {
2008    let candidates = source_selector_candidates_at_position(state, document, position);
2009    if candidates.is_empty() {
2010        return Value::Null;
2011    };
2012    let include_declaration = include_declaration_from_params(params);
2013    let mut locations = Vec::new();
2014    if include_declaration {
2015        locations.extend(
2016            style_selector_definitions_for_source_candidates(
2017                state,
2018                candidates.as_slice(),
2019                document.workspace_folder_uri.as_deref(),
2020            )
2021            .into_iter()
2022            .map(|(uri, definition)| json!({ "uri": uri, "range": definition.range })),
2023        );
2024    }
2025    for candidate in candidates {
2026        if candidate.kind == "sourceSelectorPrefixReference" {
2027            let definitions = style_selector_definitions_from_open_documents(
2028                state,
2029                "",
2030                document.workspace_folder_uri.as_deref(),
2031            );
2032            for selector_name in source_candidate_selector_names(
2033                &candidate,
2034                definitions.as_slice(),
2035                candidate.target_style_uri.as_deref(),
2036            ) {
2037                locations.extend(selector_reference_locations_from_open_documents(
2038                    state,
2039                    selector_name.as_str(),
2040                    document.workspace_folder_uri.as_deref(),
2041                    candidate.target_style_uri.as_deref(),
2042                ));
2043            }
2044        } else {
2045            locations.extend(selector_reference_locations_from_open_documents(
2046                state,
2047                candidate.name.as_str(),
2048                document.workspace_folder_uri.as_deref(),
2049                candidate.target_style_uri.as_deref(),
2050            ));
2051        }
2052    }
2053    locations.sort_by_key(location_sort_key);
2054    locations.dedup();
2055
2056    if locations.is_empty() {
2057        Value::Null
2058    } else {
2059        json!(locations)
2060    }
2061}
2062
2063fn resolve_source_lsp_completion(
2064    state: &LspShellState,
2065    document: &LspTextDocumentState,
2066    params: Option<&Value>,
2067) -> Value {
2068    let Some(position) = lsp_position_from_params(params) else {
2069        return Value::Null;
2070    };
2071    let Some(context) = source_completion_context_at_position(state, document, position) else {
2072        return Value::Null;
2073    };
2074    let inferred_target_style_uri = context.target_style_uri.clone().or_else(|| {
2075        source_selector_candidate_at_position(state, document, position)
2076            .and_then(|candidate| candidate.target_style_uri)
2077    });
2078    let target_style_uri = inferred_target_style_uri
2079        .as_deref()
2080        .map(|uri| external_document_uri_for_query_uri(state, uri));
2081
2082    let candidates = style_selector_definitions_from_open_documents(
2083        state,
2084        "",
2085        document.workspace_folder_uri.as_deref(),
2086    )
2087    .into_iter()
2088    .filter(|(uri, _)| {
2089        target_style_uri
2090            .as_deref()
2091            .is_none_or(|target_uri| file_uri_equivalent(target_uri, uri))
2092    })
2093    .map(|(uri, definition)| {
2094        let file_uri = target_style_uri
2095            .as_deref()
2096            .filter(|target_uri| file_uri_equivalent(target_uri, uri.as_str()))
2097            .map(ToString::to_string)
2098            .unwrap_or(uri);
2099        OmenaQueryCompletionCandidateV0 {
2100            file_uri,
2101            name: definition.name,
2102            kind: definition.kind,
2103            range: definition.range,
2104            source: definition.source,
2105        }
2106    })
2107    .collect::<Vec<_>>();
2108    let completion = summarize_omena_query_source_completion_at_position(
2109        document.uri.as_str(),
2110        position,
2111        candidates.as_slice(),
2112        target_style_uri.as_deref(),
2113        context.value_prefix.as_deref(),
2114        context.preferred_selector_names.as_slice(),
2115    );
2116    let items: Vec<Value> = completion
2117        .items
2118        .into_iter()
2119        .map(|item| lsp_completion_item_from_query(completion.file_kind, item))
2120        .collect();
2121
2122    json!({
2123        "isIncomplete": false,
2124        "items": items,
2125    })
2126}
2127
2128struct SourceCompletionContext {
2129    target_style_uri: Option<String>,
2130    value_prefix: Option<String>,
2131    preferred_selector_names: Vec<String>,
2132}
2133
2134fn source_completion_context_at_position(
2135    state: &LspShellState,
2136    document: &LspTextDocumentState,
2137    position: ParserPositionV0,
2138) -> Option<SourceCompletionContext> {
2139    let offset = byte_offset_for_parser_position(document.text.as_str(), position)?;
2140    if let Some(target) = document
2141        .source_syntax_index
2142        .type_fact_targets
2143        .iter()
2144        .find(|target| offset >= target.byte_span.start && offset <= target.byte_span.end)
2145    {
2146        return Some(SourceCompletionContext {
2147            target_style_uri: target.target_style_uri.clone(),
2148            value_prefix: (!target.prefix.is_empty()).then(|| target.prefix.clone()),
2149            preferred_selector_names: source_completion_value_domain_selectors_for_target(
2150                document,
2151                target.byte_span,
2152                target.target_style_uri.as_deref(),
2153            ),
2154        });
2155    }
2156    if let Some(candidate) = source_selector_candidates_at_position(state, document, position)
2157        .into_iter()
2158        .find(|candidate| {
2159            candidate.kind == "sourceSelectorReference"
2160                || candidate.kind == "sourceSelectorPrefixReference"
2161        })
2162        && let Some(span) = byte_span_for_parser_range(document.text.as_str(), candidate.range)
2163    {
2164        return Some(SourceCompletionContext {
2165            target_style_uri: candidate.target_style_uri.clone(),
2166            value_prefix: source_completion_prefix_for_terminal_offset(
2167                document.text.as_str(),
2168                span,
2169                offset,
2170            ),
2171            preferred_selector_names: Vec::new(),
2172        });
2173    }
2174    if let Some(access) = document
2175        .source_syntax_index
2176        .style_property_accesses
2177        .iter()
2178        .find(|access| offset >= access.byte_span.start && offset <= access.byte_span.end)
2179    {
2180        let target_style_uri = access
2181            .target_style_uri
2182            .clone()
2183            .or_else(|| source_completion_target_uri_for_span(document, access.byte_span));
2184        return Some(SourceCompletionContext {
2185            target_style_uri,
2186            value_prefix: source_completion_prefix_for_terminal_offset(
2187                document.text.as_str(),
2188                access.byte_span,
2189                offset,
2190            ),
2191            preferred_selector_names: Vec::new(),
2192        });
2193    }
2194    if let Some(reference) = document
2195        .source_syntax_index
2196        .selector_references
2197        .iter()
2198        .find(|reference| offset >= reference.byte_span.start && offset <= reference.byte_span.end)
2199    {
2200        let target_style_uri = reference
2201            .target_style_uri
2202            .clone()
2203            .or_else(|| source_completion_target_uri_for_span(document, reference.byte_span));
2204        return Some(SourceCompletionContext {
2205            target_style_uri,
2206            value_prefix: source_completion_prefix_for_terminal_offset(
2207                document.text.as_str(),
2208                reference.byte_span,
2209                offset,
2210            ),
2211            preferred_selector_names: Vec::new(),
2212        });
2213    }
2214    if document
2215        .source_syntax_index
2216        .class_string_literals
2217        .iter()
2218        .any(|span| offset >= span.start && offset <= span.end)
2219    {
2220        let span = document
2221            .source_syntax_index
2222            .class_string_literals
2223            .iter()
2224            .find(|span| offset >= span.start && offset <= span.end)
2225            .copied()?;
2226        return Some(SourceCompletionContext {
2227            target_style_uri: None,
2228            value_prefix: source_completion_class_token_prefix_from_span(
2229                document.text.as_str(),
2230                span,
2231                offset,
2232            ),
2233            preferred_selector_names: Vec::new(),
2234        });
2235    }
2236    None
2237}
2238
2239fn source_completion_target_uri_for_span(
2240    document: &LspTextDocumentState,
2241    span: ParserByteSpanV0,
2242) -> Option<String> {
2243    let range = parser_range_for_byte_span(document.text.as_str(), span);
2244    document
2245        .source_selector_candidates
2246        .iter()
2247        .find(|candidate| {
2248            candidate.range == range
2249                || parser_range_contains_position(&candidate.range, range.start)
2250                || parser_range_contains_position(&candidate.range, range.end)
2251        })
2252        .and_then(|candidate| candidate.target_style_uri.clone())
2253}
2254
2255fn byte_span_for_parser_range(source: &str, range: ParserRangeV0) -> Option<ParserByteSpanV0> {
2256    Some(ParserByteSpanV0 {
2257        start: byte_offset_for_parser_position(source, range.start)?,
2258        end: byte_offset_for_parser_position(source, range.end)?,
2259    })
2260}
2261
2262fn source_completion_value_domain_selectors_for_target(
2263    document: &LspTextDocumentState,
2264    byte_span: ParserByteSpanV0,
2265    target_style_uri: Option<&str>,
2266) -> Vec<String> {
2267    let mut selectors = document
2268        .source_syntax_index
2269        .selector_references
2270        .iter()
2271        .filter(|reference| reference.byte_span == byte_span)
2272        .filter(|reference| reference.match_kind == SourceSelectorReferenceMatchKind::Exact)
2273        .filter(|reference| {
2274            target_style_uri.is_none_or(|target_uri| {
2275                reference
2276                    .target_style_uri
2277                    .as_deref()
2278                    .is_some_and(|reference_uri| file_uri_equivalent(reference_uri, target_uri))
2279            })
2280        })
2281        .filter_map(|reference| reference.selector_name.clone())
2282        .collect::<Vec<_>>();
2283    selectors.sort();
2284    selectors.dedup();
2285    selectors
2286}
2287
2288fn source_completion_prefix_for_terminal_offset(
2289    source: &str,
2290    span: ParserByteSpanV0,
2291    offset: usize,
2292) -> Option<String> {
2293    (offset >= span.end).then(|| source_completion_prefix_from_span(source, span, offset))?
2294}
2295
2296fn source_completion_prefix_from_span(
2297    source: &str,
2298    span: ParserByteSpanV0,
2299    offset: usize,
2300) -> Option<String> {
2301    let end = offset.min(span.end);
2302    if end < span.start {
2303        return None;
2304    }
2305    let prefix = source.get(span.start..end)?;
2306    if prefix.is_empty() {
2307        return None;
2308    }
2309    if prefix.chars().all(is_css_identifier_continue) {
2310        Some(prefix.to_string())
2311    } else {
2312        None
2313    }
2314}
2315
2316fn source_completion_class_token_prefix_from_span(
2317    source: &str,
2318    span: ParserByteSpanV0,
2319    offset: usize,
2320) -> Option<String> {
2321    let end = offset.min(span.end);
2322    if end < span.start {
2323        return None;
2324    }
2325    let prefix = source.get(span.start..end)?;
2326    let token = prefix
2327        .rsplit(|ch: char| ch.is_ascii_whitespace())
2328        .next()
2329        .unwrap_or_default();
2330    if token.is_empty() {
2331        return None;
2332    }
2333    if token.chars().all(is_css_identifier_continue) {
2334        Some(token.to_string())
2335    } else {
2336        None
2337    }
2338}
2339
2340fn source_selector_candidate_for_params(
2341    state: &LspShellState,
2342    params: Option<&Value>,
2343) -> Option<(String, LspStyleHoverCandidate)> {
2344    let document_uri = document_uri_from_params(params);
2345    let position = lsp_position_from_params(params)?;
2346    let document = state.document(document_uri.as_str())?;
2347    if is_style_document_uri(document.uri.as_str()) {
2348        return None;
2349    }
2350    source_selector_candidate_at_position(state, document, position)
2351        .map(|candidate| (document_uri, candidate))
2352}
2353
2354fn source_selector_candidate_at_position(
2355    state: &LspShellState,
2356    document: &LspTextDocumentState,
2357    position: ParserPositionV0,
2358) -> Option<LspStyleHoverCandidate> {
2359    source_selector_candidates_at_position(state, document, position)
2360        .into_iter()
2361        .next()
2362}
2363
2364fn source_selector_candidates_at_position(
2365    state: &LspShellState,
2366    document: &LspTextDocumentState,
2367    position: ParserPositionV0,
2368) -> Vec<LspStyleHoverCandidate> {
2369    collect_source_selector_reference_candidates(state, document)
2370        .into_iter()
2371        .filter(|candidate| parser_range_contains_position(&candidate.range, position))
2372        .collect()
2373}
2374
2375fn collect_source_selector_reference_candidates(
2376    state: &LspShellState,
2377    document: &LspTextDocumentState,
2378) -> Vec<LspStyleHoverCandidate> {
2379    resolve_source_provider_candidates(state, document).matched
2380}
2381
2382fn resolve_source_provider_candidates(
2383    state: &LspShellState,
2384    document: &LspTextDocumentState,
2385) -> SourceProviderCandidateResolution {
2386    let source_candidates = collect_source_class_reference_candidates(document);
2387    let mut definitions = style_selector_definitions_from_open_documents(
2388        state,
2389        "",
2390        document.workspace_folder_uri.as_deref(),
2391    );
2392    for candidate in &source_candidates {
2393        if let Some(target_uri) = candidate.target_style_uri.as_deref()
2394            && !definitions
2395                .iter()
2396                .any(|(uri, _)| file_uri_equivalent(uri, target_uri))
2397        {
2398            definitions.extend(style_selector_definitions_from_uri(state, target_uri));
2399        }
2400    }
2401    let query_definitions = definitions
2402        .iter()
2403        .map(|(uri, definition)| query_style_selector_definition_for_matching(uri, definition))
2404        .collect::<Vec<_>>();
2405    let resolution = resolve_omena_query_source_provider_candidates(
2406        source_candidates
2407            .iter()
2408            .map(query_source_selector_candidate_for_matching)
2409            .collect(),
2410        query_definitions.as_slice(),
2411    );
2412
2413    SourceProviderCandidateResolution {
2414        matched: resolution
2415            .matched
2416            .into_iter()
2417            .map(lsp_source_selector_candidate_from_query)
2418            .collect(),
2419        unresolved: resolution
2420            .unresolved
2421            .into_iter()
2422            .map(lsp_source_selector_candidate_from_query)
2423            .collect(),
2424    }
2425}
2426
2427fn collect_source_class_reference_candidates(
2428    document: &LspTextDocumentState,
2429) -> Vec<LspStyleHoverCandidate> {
2430    document.source_selector_candidates.clone()
2431}
2432
2433fn source_selector_candidates_from_index(
2434    document: &LspTextDocumentState,
2435    index: &SourceSyntaxIndex,
2436) -> Vec<LspStyleHoverCandidate> {
2437    let mut candidates: Vec<LspStyleHoverCandidate> = index
2438        .selector_references
2439        .iter()
2440        .map(|reference| source_reference_candidate(document, reference))
2441        .collect();
2442    candidates.sort();
2443    candidates.dedup();
2444    candidates
2445}
2446
2447fn build_source_syntax_index(
2448    document: &LspTextDocumentState,
2449    resolution_inputs: &omena_query::OmenaQueryStyleResolutionInputsV0,
2450) -> SourceSyntaxIndex {
2451    if is_style_document_uri(document.uri.as_str()) {
2452        return SourceSyntaxIndex::default();
2453    }
2454
2455    let imports = collect_source_imports(document, resolution_inputs);
2456    summarize_omena_query_source_syntax_index_for_source_language(
2457        document.uri.as_str(),
2458        document.text.as_str(),
2459        Some(document.language_id.as_str()),
2460        imports.imported_style_bindings,
2461        imports.classnames_bind_bindings,
2462    )
2463}
2464
2465#[derive(Debug, Clone, PartialEq, Eq)]
2466struct SourceImportIndex {
2467    imported_style_bindings: Vec<ImportedStyleBinding>,
2468    classnames_bind_bindings: Vec<String>,
2469}
2470
2471fn collect_source_imports(
2472    document: &LspTextDocumentState,
2473    resolution_inputs: &omena_query::OmenaQueryStyleResolutionInputsV0,
2474) -> SourceImportIndex {
2475    let source = document.text.as_str();
2476    let mut imports = SourceImportIndex {
2477        imported_style_bindings: Vec::new(),
2478        classnames_bind_bindings: Vec::new(),
2479    };
2480    let summary = summarize_omena_query_source_import_declarations_for_source_language(
2481        document.uri.as_str(),
2482        source,
2483        Some(document.language_id.as_str()),
2484    );
2485    for import in summary.imports {
2486        if import.specifier == "classnames/bind" {
2487            imports.classnames_bind_bindings.push(import.binding);
2488        } else if StyleLanguage::from_module_path(import.specifier.as_str()).is_some()
2489            && let Some(style_uri) =
2490                resolve_omena_query_style_uri_for_specifier_with_resolution_inputs(
2491                    document.uri.as_str(),
2492                    document.workspace_folder_uri.as_deref(),
2493                    import.specifier.as_str(),
2494                    resolution_inputs,
2495                )
2496        {
2497            imports.imported_style_bindings.push(ImportedStyleBinding {
2498                binding: import.binding,
2499                style_uri,
2500            });
2501        }
2502    }
2503    if is_vue_document(document) && has_vue_module_style_block(source) {
2504        for binding in collect_omena_query_vue_style_module_bindings(
2505            document.uri.as_str(),
2506            source,
2507            Some(document.language_id.as_str()),
2508        ) {
2509            imports.imported_style_bindings.push(ImportedStyleBinding {
2510                binding,
2511                style_uri: document.uri.clone(),
2512            });
2513        }
2514    }
2515    imports
2516        .imported_style_bindings
2517        .sort_by(|left, right| left.binding.cmp(&right.binding));
2518    imports
2519        .imported_style_bindings
2520        .dedup_by(|left, right| left.binding == right.binding && left.style_uri == right.style_uri);
2521    imports.classnames_bind_bindings.sort();
2522    imports.classnames_bind_bindings.dedup();
2523    imports
2524}
2525
2526fn is_vue_document(document: &LspTextDocumentState) -> bool {
2527    document.language_id == "vue" || document.uri.ends_with(".vue")
2528}
2529
2530fn document_has_style_index(document: &LspTextDocumentState) -> bool {
2531    is_style_document_uri(document.uri.as_str()) || document.style_summary.is_some()
2532}
2533
2534fn has_vue_module_style_block(source: &str) -> bool {
2535    let lower = source.to_ascii_lowercase();
2536    let mut cursor = 0usize;
2537    while let Some(relative_start) = lower[cursor..].find("<style") {
2538        let tag_start = cursor + relative_start;
2539        let Some(relative_tag_end) = lower[tag_start..].find('>') else {
2540            break;
2541        };
2542        let tag = &lower[tag_start..tag_start + relative_tag_end + 1];
2543        if tag.contains("module") {
2544            return true;
2545        }
2546        cursor = tag_start + relative_tag_end + 1;
2547    }
2548    false
2549}
2550
2551fn source_reference_candidate(
2552    document: &LspTextDocumentState,
2553    reference: &SourceSelectorReferenceFact,
2554) -> LspStyleHoverCandidate {
2555    let name = reference.selector_name.clone().unwrap_or_else(|| {
2556        document.text[reference.byte_span.start..reference.byte_span.end].to_string()
2557    });
2558    LspStyleHoverCandidate {
2559        kind: match reference.match_kind {
2560            SourceSelectorReferenceMatchKind::Exact => "sourceSelectorReference",
2561            SourceSelectorReferenceMatchKind::Prefix => "sourceSelectorPrefixReference",
2562        },
2563        name,
2564        range: parser_range_for_byte_span(document.text.as_str(), reference.byte_span),
2565        source: "omenaQuerySourceSyntaxIndex",
2566        target_style_uri: reference.target_style_uri.clone(),
2567        namespace: None,
2568    }
2569}
2570
2571fn style_selector_definitions_from_open_documents(
2572    state: &LspShellState,
2573    selector_name: &str,
2574    workspace_folder_uri: Option<&str>,
2575) -> Vec<(String, LspStyleHoverCandidate)> {
2576    let mut definitions = Vec::new();
2577    for document in state.documents.values() {
2578        if !document_has_style_index(document)
2579            || !workspace_folder_compatible(workspace_folder_uri, document)
2580        {
2581            continue;
2582        }
2583        let Some((_, candidates)) = style_hover_candidates_for_document(document) else {
2584            continue;
2585        };
2586        definitions.extend(
2587            candidates
2588                .into_iter()
2589                .filter(|candidate| {
2590                    candidate.kind == "selector"
2591                        && (selector_name.is_empty() || candidate.name == selector_name)
2592                })
2593                .map(|candidate| (document.uri.clone(), candidate)),
2594        );
2595    }
2596    definitions.sort_by_key(|(uri, candidate)| {
2597        (
2598            uri.clone(),
2599            candidate.range.start.line,
2600            candidate.range.start.character,
2601        )
2602    });
2603    definitions
2604}
2605
2606fn style_selector_definitions_from_uri(
2607    state: &LspShellState,
2608    uri: &str,
2609) -> Vec<(String, LspStyleHoverCandidate)> {
2610    style_hover_candidates_for_uri(state, uri)
2611        .map(|(_, candidates)| {
2612            candidates
2613                .into_iter()
2614                .filter(|candidate| candidate.kind == "selector")
2615                .map(|candidate| (uri.to_string(), candidate))
2616                .collect()
2617        })
2618        .unwrap_or_default()
2619}
2620
2621fn style_selector_definitions_for_source_candidate(
2622    state: &LspShellState,
2623    candidate: &LspStyleHoverCandidate,
2624    workspace_folder_uri: Option<&str>,
2625) -> Vec<(String, LspStyleHoverCandidate)> {
2626    let mut definitions = style_selector_definitions_from_open_documents(
2627        state,
2628        source_candidate_definition_lookup_name(candidate),
2629        workspace_folder_uri,
2630    );
2631    if let Some(target_uri) = candidate.target_style_uri.as_deref()
2632        && !definitions
2633            .iter()
2634            .any(|(uri, _)| file_uri_equivalent(uri, target_uri))
2635    {
2636        definitions.extend(style_selector_definitions_from_uri(state, target_uri));
2637    }
2638    let query_definitions = definitions
2639        .iter()
2640        .map(|(uri, definition)| query_style_selector_definition_for_matching(uri, definition))
2641        .collect::<Vec<_>>();
2642    let matched_identities = resolve_omena_query_style_selector_definitions_for_source_candidate(
2643        &query_source_selector_candidate_for_matching(candidate),
2644        query_definitions.as_slice(),
2645    )
2646    .into_iter()
2647    .map(|definition| {
2648        query_definition_identity(
2649            definition.uri.as_str(),
2650            definition.name.as_str(),
2651            definition.range,
2652        )
2653    })
2654    .collect::<BTreeSet<_>>();
2655
2656    definitions
2657        .into_iter()
2658        .filter(|(uri, definition)| {
2659            matched_identities.contains(&query_definition_identity(
2660                uri.as_str(),
2661                definition.name.as_str(),
2662                definition.range,
2663            ))
2664        })
2665        .collect()
2666}
2667
2668fn style_selector_definitions_for_source_candidates(
2669    state: &LspShellState,
2670    candidates: &[LspStyleHoverCandidate],
2671    workspace_folder_uri: Option<&str>,
2672) -> Vec<(String, LspStyleHoverCandidate)> {
2673    let mut definitions = candidates
2674        .iter()
2675        .flat_map(|candidate| {
2676            style_selector_definitions_for_source_candidate(state, candidate, workspace_folder_uri)
2677        })
2678        .collect::<Vec<_>>();
2679    definitions.sort_by_key(|(uri, definition)| {
2680        (
2681            uri.clone(),
2682            definition.range.start.line,
2683            definition.range.start.character,
2684            definition.name.clone(),
2685        )
2686    });
2687    definitions.dedup_by(|left, right| {
2688        left.0 == right.0 && left.1.name == right.1.name && left.1.range == right.1.range
2689    });
2690    definitions
2691}
2692
2693fn render_source_hover_definitions_markdown(
2694    state: &LspShellState,
2695    definitions: &[(String, LspStyleHoverCandidate)],
2696) -> Option<String> {
2697    let parts = definitions
2698        .iter()
2699        .filter_map(|(uri, definition)| {
2700            style_text_for_uri(state, uri).map(|text| {
2701                render_style_hover_candidate_markdown(uri.as_str(), text.as_str(), definition)
2702            })
2703        })
2704        .collect::<Vec<_>>();
2705    if parts.is_empty() {
2706        None
2707    } else {
2708        Some(parts.join("\n\n---\n\n"))
2709    }
2710}
2711
2712fn source_candidate_definition_lookup_name(candidate: &LspStyleHoverCandidate) -> &str {
2713    if candidate.kind == "sourceSelectorPrefixReference" {
2714        ""
2715    } else {
2716        candidate.name.as_str()
2717    }
2718}
2719
2720fn first_style_document_for_workspace<'a>(
2721    state: &'a LspShellState,
2722    workspace_folder_uri: Option<&str>,
2723) -> Option<(String, &'a LspTextDocumentState)> {
2724    state
2725        .documents
2726        .values()
2727        .filter(|document| document_has_style_index(document))
2728        .filter(|document| workspace_folder_compatible(workspace_folder_uri, document))
2729        .map(|document| (document.uri.clone(), document))
2730        .next()
2731}
2732
2733fn resolve_selector_rename(
2734    state: &LspShellState,
2735    workspace_folder_uri: Option<&str>,
2736    target_style_uri: Option<&str>,
2737    selector_name: &str,
2738    new_name: &str,
2739) -> Value {
2740    let query_definitions =
2741        style_selector_definitions_from_open_documents(state, selector_name, workspace_folder_uri)
2742            .iter()
2743            .map(|(uri, definition)| query_style_selector_definition_for_matching(uri, definition))
2744            .collect::<Vec<_>>();
2745    let query_target_style_uri = query_target_style_uri_for_matching(target_style_uri);
2746    let mut query_references = Vec::new();
2747    for document in state.documents.values() {
2748        if is_style_document_uri(document.uri.as_str()) {
2749            continue;
2750        }
2751        if !workspace_folder_compatible(workspace_folder_uri, document) {
2752            continue;
2753        }
2754        query_references.extend(
2755            collect_source_selector_reference_candidates(state, document)
2756                .iter()
2757                .map(|candidate| {
2758                    query_source_selector_reference_edit_target_for_matching(document, candidate)
2759                }),
2760        );
2761    }
2762    let rename_plan = summarize_omena_query_rename_plan(
2763        selector_name,
2764        new_name,
2765        query_target_style_uri.as_deref(),
2766        query_definitions.as_slice(),
2767        query_references.as_slice(),
2768    );
2769    if rename_plan.edits.is_empty() {
2770        return Value::Null;
2771    }
2772
2773    let mut changes: BTreeMap<String, Vec<Value>> = BTreeMap::new();
2774    for edit in rename_plan.edits {
2775        let edit_uri = external_document_uri_for_query_uri(state, edit.uri.as_str());
2776        changes.entry(edit_uri).or_default().push(json!({
2777            "range": edit.range,
2778            "newText": edit.new_text,
2779        }));
2780    }
2781    for edits in changes.values_mut() {
2782        edits.sort_by_key(|edit| {
2783            let line = edit
2784                .pointer("/range/start/line")
2785                .and_then(Value::as_u64)
2786                .unwrap_or_default();
2787            let character = edit
2788                .pointer("/range/start/character")
2789                .and_then(Value::as_u64)
2790                .unwrap_or_default();
2791            (line, character)
2792        });
2793    }
2794
2795    let mut response_changes = serde_json::Map::new();
2796    for (uri, edits) in changes {
2797        response_changes.insert(uri, json!(edits));
2798    }
2799    json!({
2800        "changes": Value::Object(response_changes),
2801    })
2802}
2803
2804fn external_document_uri_for_query_uri(state: &LspShellState, uri: &str) -> String {
2805    state
2806        .document(uri)
2807        .map(|document| document.uri.clone())
2808        .unwrap_or_else(|| uri.to_string())
2809}
2810
2811fn render_style_hover_candidate_markdown(
2812    document_uri: &str,
2813    source: &str,
2814    candidate: &LspStyleHoverCandidate,
2815) -> String {
2816    let location = format!(
2817        "{}:{}",
2818        file_label_from_uri(document_uri),
2819        candidate.range.start.line + 1
2820    );
2821    let render_parts = summarize_omena_query_style_hover_render_parts(
2822        source,
2823        candidate.kind,
2824        candidate.name.as_str(),
2825        candidate.range.start,
2826    );
2827    match candidate.kind {
2828        "selector" => {
2829            format!(
2830                "**`.{}`** - _{}_\n\n```scss\n{}\n```",
2831                candidate.name, location, render_parts.snippet
2832            )
2833        }
2834        "customPropertyReference" => {
2835            format!(
2836                "**`var({})`** - _{}_\n\n```scss\n{}\n```",
2837                candidate.name, location, render_parts.snippet
2838            )
2839        }
2840        "customPropertyDeclaration" => {
2841            format!(
2842                "**`{}`** - _{}_\n\n```scss\n{}\n```",
2843                candidate.name, location, render_parts.snippet
2844            )
2845        }
2846        kind if is_sass_symbol_candidate_kind(kind) => {
2847            render_sass_symbol_hover_markdown(candidate, location.as_str(), &render_parts)
2848        }
2849        _ => candidate.name.clone(),
2850    }
2851}
2852
2853fn render_sass_symbol_hover_markdown(
2854    candidate: &LspStyleHoverCandidate,
2855    location: &str,
2856    render_parts: &omena_query::OmenaQueryStyleHoverRenderPartsV0,
2857) -> String {
2858    let label = render_sass_symbol_label(candidate);
2859    match sass_symbol_kind_from_candidate_kind(candidate.kind) {
2860        Some("variable") if is_sass_symbol_declaration_kind(candidate.kind) => {
2861            if let Some(value) = render_parts.value.as_deref() {
2862                return format!(
2863                    "**`{}`** - _{}_\n\nValue: `{}`\n\n```scss\n{}\n```",
2864                    label, location, value, render_parts.snippet
2865                );
2866            }
2867            format!(
2868                "**`{}`** - _{}_\n\n```scss\n{}\n```",
2869                label, location, render_parts.snippet
2870            )
2871        }
2872        Some("mixin" | "function") if is_sass_symbol_declaration_kind(candidate.kind) => {
2873            let rendered_label = render_parts.signature.as_deref().unwrap_or(label.as_str());
2874            format!(
2875                "**`{}`** - _{}_\n\n```scss\n{}\n```",
2876                rendered_label, location, render_parts.snippet
2877            )
2878        }
2879        _ => {
2880            format!(
2881                "**`{}`** - _{}_\n\n```scss\n{}\n```",
2882                label, location, render_parts.snippet
2883            )
2884        }
2885    }
2886}
2887
2888#[cfg(test)]
2889mod tests;