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 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}