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