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