Skip to main content

omena_lsp_server/
workspace_index.rs

1use crate::protocol::{
2    file_uri_to_path, is_style_document_uri, normalize_path, path_to_file_uri, style_language_label,
3};
4use crate::source_document_cache::{
5    load_source_document_index_sidecar, source_document_text_hash,
6    store_source_document_index_sidecar,
7};
8use crate::{
9    LspShellState, LspTextDocumentState, LspWorkspaceFolderState, lsp_text_document_state,
10    lsp_text_document_state_with_source_syntax_index,
11};
12use omena_query::{OmenaQueryStyleResolutionInputsV0, StyleLanguage};
13use std::{
14    collections::{BTreeMap, BTreeSet},
15    fs,
16    path::{Path, PathBuf},
17    time::Instant,
18};
19
20const WORKSPACE_INDEX_FILE_LIMIT: usize = 512;
21const WORKSPACE_STYLE_INDEX_DIR_LIMIT: usize = 2048;
22const WORKSPACE_STYLE_INDEX_TIME_BUDGET_MS: u128 = 50;
23
24pub(crate) fn index_workspace_style_files(state: &mut LspShellState) {
25    let mut budget = WorkspaceStyleIndexBudget::with_defaults();
26    index_workspace_style_files_with_budget(state, &mut budget);
27    crate::admit_foreign_style_dependencies_for_indexed_style_documents(state);
28    crate::refresh_external_sifs_for_state(state);
29}
30
31#[derive(Debug, Clone)]
32pub struct LspWorkspaceIndexJobV0 {
33    pub revision: u64,
34    pub progress_token: Option<String>,
35    pub folders: Vec<LspWorkspaceFolderState>,
36    pub resolution_inputs_by_workspace_uri: BTreeMap<String, OmenaQueryStyleResolutionInputsV0>,
37    pub indexed_document_hashes_by_uri: BTreeMap<String, String>,
38    pub open_document_uris: BTreeSet<String>,
39    pub pending_file_uris: Vec<String>,
40}
41
42#[derive(Debug, Clone)]
43pub struct LspWorkspaceIndexCacheStorageV0 {
44    cache_storage: crate::cache_root::LspCacheStorageConfigV0,
45}
46
47#[derive(Debug, Clone)]
48pub struct LspWorkspaceIndexResultV0 {
49    pub revision: u64,
50    pub progress_token: Option<String>,
51    pub documents: Vec<LspTextDocumentState>,
52    pub pending_file_uris: Vec<String>,
53    pub indexed_count: usize,
54    pub pending_file_count: usize,
55    pub exhausted: bool,
56}
57
58pub fn prepare_background_workspace_index_job(state: &mut LspShellState) -> LspWorkspaceIndexJobV0 {
59    state.workspace_index_revision = state.workspace_index_revision.saturating_add(1);
60    LspWorkspaceIndexJobV0 {
61        revision: state.workspace_index_revision,
62        progress_token: None,
63        folders: state.workspace_runtime_registry.folder_snapshots(),
64        resolution_inputs_by_workspace_uri: state
65            .resolution
66            .workspace_style_resolution_inputs
67            .clone(),
68        indexed_document_hashes_by_uri: state
69            .documents
70            .values()
71            .map(|document| (document.uri.clone(), document.text_hash.clone()))
72            .collect(),
73        open_document_uris: state
74            .open_document_uris
75            .iter()
76            .flat_map(|file_id| {
77                [
78                    state
79                        .document_for_file_id(*file_id)
80                        .map(|document| document.uri.clone()),
81                    state
82                        .document_storage_uri_for_file_id(*file_id)
83                        .map(ToString::to_string),
84                ]
85                .into_iter()
86                .flatten()
87            })
88            .collect(),
89        pending_file_uris: Vec::new(),
90    }
91}
92
93pub fn prepare_background_workspace_index_cache_storage(
94    state: &LspShellState,
95) -> LspWorkspaceIndexCacheStorageV0 {
96    LspWorkspaceIndexCacheStorageV0 {
97        cache_storage: state.resolution.cache_storage.clone(),
98    }
99}
100
101pub fn prepare_background_workspace_index_continuation_job(
102    state: &mut LspShellState,
103    pending_file_uris: Vec<String>,
104) -> LspWorkspaceIndexJobV0 {
105    let mut job = prepare_background_workspace_index_job(state);
106    job.pending_file_uris = pending_file_uris;
107    job
108}
109
110pub fn collect_background_workspace_index(
111    job: LspWorkspaceIndexJobV0,
112) -> LspWorkspaceIndexResultV0 {
113    collect_background_workspace_index_with_cache_storage(
114        job,
115        LspWorkspaceIndexCacheStorageV0 {
116            cache_storage: crate::cache_root::LspCacheStorageConfigV0::default(),
117        },
118    )
119}
120
121pub fn collect_background_workspace_index_with_cache_storage(
122    job: LspWorkspaceIndexJobV0,
123    cache_storage: LspWorkspaceIndexCacheStorageV0,
124) -> LspWorkspaceIndexResultV0 {
125    let mut documents = Vec::new();
126    let candidate_uris = if job.pending_file_uris.is_empty() {
127        collect_workspace_index_candidate_uris(&job)
128    } else {
129        job.pending_file_uris.clone()
130    };
131    let mut budget = WorkspaceStyleIndexBudget::with_defaults();
132    let pending_file_uris = collect_workspace_index_documents_from_candidates(
133        &job,
134        &cache_storage.cache_storage,
135        candidate_uris,
136        &mut budget,
137        &mut documents,
138    );
139    let indexed_count = documents.len();
140    let pending_file_count = pending_file_uris.len();
141    LspWorkspaceIndexResultV0 {
142        revision: job.revision,
143        progress_token: job.progress_token,
144        documents,
145        pending_file_uris,
146        indexed_count,
147        pending_file_count,
148        exhausted: budget.exhausted,
149    }
150}
151
152pub fn apply_background_workspace_index_result(
153    state: &mut LspShellState,
154    result: LspWorkspaceIndexResultV0,
155) -> bool {
156    if result.revision != state.workspace_index_revision {
157        return false;
158    }
159    let mut previous_bridge_sources = Vec::new();
160    let mut indexed_style_uris = Vec::new();
161    for document in result.documents {
162        if state.has_open_document_uri(document.uri.as_str()) {
163            continue;
164        }
165        let Some(current_owner_uri) = state
166            .workspace_runtime_registry
167            .resolve_owner_uri(document.uri.as_str())
168        else {
169            continue;
170        };
171        if document.workspace_folder_uri.as_deref() != Some(current_owner_uri.as_str()) {
172            continue;
173        }
174        let uri = document.uri.clone();
175        if is_style_document_uri(uri.as_str()) {
176            previous_bridge_sources.extend(crate::bridge_sources_for_style_uris(
177                state,
178                std::slice::from_ref(&uri),
179            ));
180            indexed_style_uris.push(uri.clone());
181        }
182        state.insert_document(uri.as_str(), document);
183    }
184    #[cfg(feature = "salsa-style-diagnostics")]
185    if !indexed_style_uris.is_empty() {
186        let mut host_slot = state.style_memo_host.borrow_mut();
187        let host = host_slot.get_or_insert_with(omena_query::OmenaQueryStyleMemoHostV0::new);
188        host.register_style_paths(indexed_style_uris.iter().cloned());
189    }
190    let admitted_foreign_uris =
191        crate::admit_foreign_style_dependencies_for_style_uris(state, indexed_style_uris.clone());
192    let mut bridge_source_uris = indexed_style_uris;
193    bridge_source_uris.extend(admitted_foreign_uris);
194    let next_bridge_sources = crate::bridge_sources_for_style_uris(state, &bridge_source_uris);
195    if state.external_sif_refresh_deferred && !bridge_source_uris.is_empty() {
196        // Newly admitted style documents mutate the corpus input: the ledger
197        // advance stales any in-flight SIF job built on the smaller corpus,
198        // and the idempotent deposit owes ONE refresh whose settle gate holds
199        // the flush until the index frontier passes — pending == 0. The
200        // immediate arm keeps its delta-scoped invariant untouched.
201        state
202            .tide_ledger
203            .advance(&[crate::tide::TideInputKindV0::DocumentSet]);
204        let affected_file_ids = bridge_source_uris
205            .iter()
206            .filter_map(|uri| state.document_file_id(uri))
207            .collect::<Vec<_>>();
208        state.tide_reopen_republish_window(crate::tide::TideDisownCauseV0::for_file_ids(
209            crate::tide::TideInputKindV0::DocumentSet,
210            affected_file_ids,
211        ));
212        crate::loop_trace!(
213            "index-admit deposits sif demand (admitted={})",
214            bridge_source_uris.len()
215        );
216        let tick = state.tide_tick;
217        state
218            .tide_sif_lane
219            .deposit(crate::tide::TideSifDemandV0::refresh(), tick);
220    }
221    crate::refresh_external_sifs_for_bridge_source_delta(
222        state,
223        &bridge_source_uris,
224        previous_bridge_sources.as_slice(),
225        next_bridge_sources.as_slice(),
226    );
227    crate::loop_trace!(
228        "index-apply admitted_style={} pending={} exhausted={}",
229        bridge_source_uris.len(),
230        result.pending_file_count,
231        result.exhausted
232    );
233    state.workspace_index_pending_file_count = result.pending_file_count;
234    state.source_type_fact_workspace_index_incomplete =
235        result.exhausted || result.pending_file_count > 0;
236    if result.exhausted {
237        state.workspace_style_index_exhausted_count += 1;
238    }
239    true
240}
241
242pub(crate) fn index_workspace_style_files_with_budget(
243    state: &mut LspShellState,
244    budget: &mut WorkspaceStyleIndexBudget,
245) {
246    let folders = state.workspace_runtime_registry.folder_snapshots();
247    for folder in folders {
248        if budget.should_stop() {
249            break;
250        }
251        let Some(path) = file_uri_to_path(folder.uri.as_str()) else {
252            continue;
253        };
254        index_workspace_style_files_from_dir(state, folder.uri.as_str(), path.as_path(), budget);
255    }
256    if budget.exhausted {
257        state.workspace_style_index_exhausted_count += 1;
258        state.source_type_fact_workspace_index_incomplete = true;
259    } else {
260        state.source_type_fact_workspace_index_incomplete = false;
261    }
262}
263
264fn index_workspace_style_files_from_dir(
265    state: &mut LspShellState,
266    workspace_folder_uri: &str,
267    dir: &Path,
268    budget: &mut WorkspaceStyleIndexBudget,
269) {
270    if budget.should_stop() || should_skip_workspace_index_dir(dir) {
271        return;
272    }
273    budget.consume_dir();
274    let Ok(entries) = fs::read_dir(dir) else {
275        return;
276    };
277    let mut entries = entries.flatten().collect::<Vec<_>>();
278    entries.sort_by_key(|entry| entry.path());
279    for entry in entries {
280        if budget.should_stop() {
281            return;
282        }
283        let path = entry.path();
284        if path.is_dir() {
285            index_workspace_style_files_from_dir(
286                state,
287                workspace_folder_uri,
288                path.as_path(),
289                budget,
290            );
291            continue;
292        }
293        let Some(language_id) = workspace_index_language_id_for_path(path.as_path()) else {
294            continue;
295        };
296        let uri = path_to_file_uri(path.as_path());
297        if state.contains_document_uri(uri.as_str()) {
298            continue;
299        }
300        let Ok(text) = fs::read_to_string(path.as_path()) else {
301            continue;
302        };
303        let workspace_owner_uri = state
304            .workspace_runtime_registry
305            .resolve_owner_uri(uri.as_str())
306            .unwrap_or_else(|| workspace_folder_uri.to_string());
307        let resolution_inputs = state
308            .resolution
309            .workspace_style_resolution_inputs
310            .get(workspace_owner_uri.as_str())
311            .cloned()
312            .unwrap_or_default();
313        state.insert_document(
314            uri.as_str(),
315            lsp_text_document_state(
316                uri.clone(),
317                Some(workspace_owner_uri),
318                language_id,
319                0,
320                text,
321                &resolution_inputs,
322            ),
323        );
324        budget.consume_indexed_file();
325    }
326}
327
328fn collect_workspace_index_candidate_uris(job: &LspWorkspaceIndexJobV0) -> Vec<String> {
329    let mut uris = Vec::new();
330    for folder in &job.folders {
331        let Some(path) = file_uri_to_path(folder.uri.as_str()) else {
332            continue;
333        };
334        collect_workspace_index_candidate_uris_from_dir(job, path.as_path(), uris.as_mut());
335    }
336    sort_workspace_index_candidate_uris(job, uris.as_mut_slice());
337    uris.dedup();
338    uris
339}
340
341fn sort_workspace_index_candidate_uris(job: &LspWorkspaceIndexJobV0, uris: &mut [String]) {
342    let open_document_dirs = workspace_index_open_document_dirs(job);
343    uris.sort_by(|left, right| {
344        workspace_index_candidate_proximity_group(&open_document_dirs, left.as_str())
345            .cmp(&workspace_index_candidate_proximity_group(
346                &open_document_dirs,
347                right.as_str(),
348            ))
349            .then_with(|| left.cmp(right))
350    });
351}
352
353fn workspace_index_open_document_dirs(job: &LspWorkspaceIndexJobV0) -> BTreeSet<PathBuf> {
354    job.open_document_uris
355        .iter()
356        .filter_map(|uri| {
357            file_uri_to_path(uri.as_str()).and_then(|path| path.parent().map(Path::to_path_buf))
358        })
359        .collect()
360}
361
362fn workspace_index_candidate_proximity_group(
363    open_document_dirs: &BTreeSet<PathBuf>,
364    uri: &str,
365) -> u8 {
366    file_uri_to_path(uri)
367        .and_then(|path| {
368            path.parent()
369                .map(|parent| open_document_dirs.contains(parent))
370        })
371        .filter(|is_near_open_document| *is_near_open_document)
372        .map_or(1, |_| 0)
373}
374
375fn collect_workspace_index_candidate_uris_from_dir(
376    job: &LspWorkspaceIndexJobV0,
377    dir: &Path,
378    uris: &mut Vec<String>,
379) {
380    if should_skip_workspace_index_dir(dir) {
381        return;
382    }
383    let Ok(entries) = fs::read_dir(dir) else {
384        return;
385    };
386    let mut entries = entries.flatten().collect::<Vec<_>>();
387    entries.sort_by_key(|entry| entry.path());
388    for entry in entries {
389        let path = entry.path();
390        if path.is_dir() {
391            collect_workspace_index_candidate_uris_from_dir(job, path.as_path(), uris);
392            continue;
393        }
394        if workspace_index_language_id_for_path(path.as_path()).is_none() {
395            continue;
396        }
397        let uri = path_to_file_uri(path.as_path());
398        if job.open_document_uris.contains(uri.as_str())
399            || job
400                .indexed_document_hashes_by_uri
401                .contains_key(uri.as_str())
402        {
403            continue;
404        }
405        uris.push(uri);
406    }
407}
408
409fn collect_workspace_index_documents_from_candidates(
410    job: &LspWorkspaceIndexJobV0,
411    cache_storage: &crate::cache_root::LspCacheStorageConfigV0,
412    candidate_uris: Vec<String>,
413    budget: &mut WorkspaceStyleIndexBudget,
414    documents: &mut Vec<LspTextDocumentState>,
415) -> Vec<String> {
416    let mut pending_file_uris = Vec::new();
417    for uri in candidate_uris {
418        if job.open_document_uris.contains(uri.as_str())
419            || job
420                .indexed_document_hashes_by_uri
421                .contains_key(uri.as_str())
422        {
423            continue;
424        }
425        if budget.should_stop() {
426            pending_file_uris.push(uri);
427            continue;
428        }
429        let Some(path) = file_uri_to_path(uri.as_str()) else {
430            continue;
431        };
432        let Some(language_id) = workspace_index_language_id_for_path(path.as_path()) else {
433            continue;
434        };
435        let Ok(text) = fs::read_to_string(path.as_path()) else {
436            continue;
437        };
438        let text_hash = source_document_text_hash(text.as_str());
439        let workspace_owner_uri = resolve_background_workspace_owner_uri(job, uri.as_str())
440            .or_else(|| {
441                job.folders
442                    .iter()
443                    .find(|folder| uri.starts_with(folder.uri.as_str()))
444                    .map(|folder| folder.uri.clone())
445            });
446        let resolution_inputs = workspace_owner_uri
447            .as_ref()
448            .and_then(|owner_uri| {
449                job.resolution_inputs_by_workspace_uri
450                    .get(owner_uri.as_str())
451            })
452            .cloned()
453            .unwrap_or_default();
454        let document = if !is_style_document_uri(uri.as_str())
455            && let Some(sidecar) = load_source_document_index_sidecar(
456                cache_storage,
457                workspace_owner_uri.as_deref(),
458                uri.as_str(),
459                language_id.as_str(),
460                text_hash.as_str(),
461                &resolution_inputs,
462            ) {
463            lsp_text_document_state_with_source_syntax_index(
464                uri,
465                workspace_owner_uri,
466                language_id,
467                0,
468                text,
469                sidecar.source_syntax_index,
470                sidecar.source_type_fact_attempts,
471                sidecar.has_unresolved_style_import,
472            )
473        } else {
474            let document = lsp_text_document_state(
475                uri,
476                workspace_owner_uri,
477                language_id,
478                0,
479                text,
480                &resolution_inputs,
481            );
482            if !is_style_document_uri(document.uri.as_str()) {
483                store_source_document_index_sidecar(
484                    cache_storage,
485                    document.workspace_folder_uri.as_deref(),
486                    document.uri.as_str(),
487                    document.language_id.as_str(),
488                    document.text_hash.as_str(),
489                    &resolution_inputs,
490                    &document.source_syntax_index,
491                    document.source_type_fact_lexical_attempts.as_slice(),
492                    document.has_unresolved_style_import,
493                );
494            }
495            document
496        };
497        documents.push(document);
498        budget.consume_indexed_file();
499    }
500    pending_file_uris
501}
502
503fn resolve_background_workspace_owner_uri(
504    job: &LspWorkspaceIndexJobV0,
505    document_uri: &str,
506) -> Option<String> {
507    let document_path = file_uri_to_path(document_uri).map(normalize_path);
508    job.folders
509        .iter()
510        .filter_map(|folder| {
511            background_workspace_owner_score(folder, document_uri, document_path.as_deref())
512                .map(|score| (score, folder.uri.clone()))
513        })
514        .max_by_key(|(score, _)| *score)
515        .map(|(_, uri)| uri)
516}
517
518fn background_workspace_owner_score(
519    folder: &LspWorkspaceFolderState,
520    document_uri: &str,
521    document_path: Option<&Path>,
522) -> Option<(u8, usize, usize)> {
523    if let (Some(root_path), Some(document_path)) = (
524        file_uri_to_path(folder.uri.as_str()).map(normalize_path),
525        document_path,
526    ) && !root_path.as_os_str().is_empty()
527        && (document_path == root_path || document_path.starts_with(root_path.as_path()))
528    {
529        return Some((1, root_path.components().count(), folder.uri.len()));
530    }
531
532    if document_uri == folder.uri
533        || document_uri
534            .strip_prefix(folder.uri.as_str())
535            .is_some_and(|suffix| suffix.starts_with('/'))
536    {
537        return Some((0, 0, folder.uri.len()));
538    }
539
540    None
541}
542
543pub(crate) struct WorkspaceStyleIndexBudget {
544    remaining_files: usize,
545    remaining_dirs: usize,
546    started_at: Instant,
547    time_budget_ms: u128,
548    pub(crate) exhausted: bool,
549}
550
551impl WorkspaceStyleIndexBudget {
552    pub(crate) fn with_defaults() -> Self {
553        Self::with_limits(
554            WORKSPACE_INDEX_FILE_LIMIT,
555            WORKSPACE_STYLE_INDEX_DIR_LIMIT,
556            WORKSPACE_STYLE_INDEX_TIME_BUDGET_MS,
557        )
558    }
559
560    pub(crate) fn with_limits(
561        remaining_files: usize,
562        remaining_dirs: usize,
563        time_budget_ms: u128,
564    ) -> Self {
565        Self {
566            remaining_files,
567            remaining_dirs,
568            started_at: Instant::now(),
569            time_budget_ms,
570            exhausted: false,
571        }
572    }
573
574    fn should_stop(&mut self) -> bool {
575        if self.remaining_files == 0
576            || self.remaining_dirs == 0
577            || self.started_at.elapsed().as_millis() >= self.time_budget_ms
578        {
579            self.exhausted = true;
580            return true;
581        }
582        false
583    }
584
585    fn consume_dir(&mut self) {
586        self.remaining_dirs = self.remaining_dirs.saturating_sub(1);
587    }
588
589    fn consume_indexed_file(&mut self) {
590        self.remaining_files = self.remaining_files.saturating_sub(1);
591    }
592}
593
594pub(crate) fn should_skip_workspace_index_dir(dir: &Path) -> bool {
595    dir.file_name()
596        .and_then(|name| name.to_str())
597        .is_some_and(|name| {
598            matches!(
599                name,
600                ".cache"
601                    | ".git"
602                    | ".next"
603                    | ".turbo"
604                    | "build"
605                    | "coverage"
606                    | "node_modules"
607                    | "out"
608                    | "target"
609            )
610        })
611}
612
613fn workspace_index_language_id_for_path(path: &Path) -> Option<String> {
614    if let Some(language) = StyleLanguage::from_module_path(path.to_string_lossy().as_ref()) {
615        return Some(style_language_label(language).to_string());
616    }
617    source_language_id_for_path(path).map(str::to_string)
618}
619
620pub(crate) fn workspace_index_language_id_for_uri(uri: &str) -> Option<String> {
621    let path = file_uri_to_path(uri)?;
622    workspace_index_language_id_for_path(path.as_path())
623}
624
625fn source_language_id_for_path(path: &Path) -> Option<&'static str> {
626    let file_name = path.file_name()?.to_str()?.to_ascii_lowercase();
627    if file_name.ends_with(".d.ts") {
628        return Some("typescript");
629    }
630    if file_name.ends_with(".html.eex") {
631        return Some("html-eex");
632    }
633    match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
634        "ts" | "mts" | "cts" => Some("typescript"),
635        "tsx" => Some("typescriptreact"),
636        "js" | "mjs" | "cjs" => Some("javascript"),
637        "jsx" => Some("javascriptreact"),
638        "vue" => Some("vue"),
639        "html" => Some("html"),
640        "svelte" => Some("svelte"),
641        "astro" => Some("astro"),
642        "md" => Some("markdown"),
643        "mdx" => Some("mdx"),
644        "liquid" => Some("liquid"),
645        "twig" => Some("twig"),
646        "njk" => Some("nunjucks"),
647        "hbs" => Some("handlebars"),
648        "erb" => Some("erb"),
649        "ejs" => Some("ejs"),
650        "heex" => Some("heex"),
651        _ => None,
652    }
653}