1use std::collections::HashSet;
2use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
3use std::sync::{Arc, Mutex, OnceLock};
4
5use arc_swap::ArcSwap;
6
7use dashmap::{DashMap, DashSet};
8use salsa::Setter;
9use tower_lsp::lsp_types::{SemanticToken, Url};
10
11use crate::db::mir_queries::{LspWorkspace, LspWsFile};
12use crate::document::ast::ParsedDoc;
13use crate::document::cache_registry::CacheRegistry;
14use crate::index::file_index::FileIndex;
15use crate::lang::autoload::Psr4Map;
16
17pub struct DocumentStore {
18 caches: CacheRegistry,
20
21 lsp_ws_files: DashMap<Url, LspWsFile>,
31 deleted_uris: DashSet<Url>,
34 workspace_files_dirty: AtomicBool,
38 lsp_workspace: Mutex<Option<LspWorkspace>>,
42 php_version: Mutex<mir_analyzer::PhpVersion>,
45 psr4: Arc<ArcSwap<Psr4Map>>,
50 analysis_session: Mutex<Option<(mir_analyzer::PhpVersion, Arc<mir_analyzer::AnalysisSession>)>>,
54 session_cache_dir: OnceLock<std::path::PathBuf>,
58 autoload_uris: std::sync::RwLock<Vec<Url>>,
63 index_ready: AtomicBool,
69 write_revision: AtomicU64,
75 reanalyze_cancel: Mutex<mir_analyzer::IndexCancel>,
80 warm_sweep_cancel: Mutex<mir_analyzer::IndexCancel>,
84 warm_sweeps_completed: AtomicU64,
87 interactive_reads: AtomicU64,
92}
93
94pub struct InteractiveReadGuard<'a>(&'a AtomicU64);
97
98impl Drop for InteractiveReadGuard<'_> {
99 fn drop(&mut self) {
100 self.0.fetch_sub(1, Ordering::Relaxed);
101 }
102}
103
104impl Default for DocumentStore {
105 fn default() -> Self {
106 Self::new()
107 }
108}
109
110impl DocumentStore {
111 pub fn new() -> Self {
112 static RAYON_STACK: OnceLock<()> = OnceLock::new();
119 RAYON_STACK.get_or_init(|| {
120 let _ = rayon::ThreadPoolBuilder::new()
121 .stack_size(16 * 1024 * 1024)
122 .build_global();
123 });
124 DocumentStore {
125 caches: CacheRegistry::new(),
126 lsp_ws_files: DashMap::new(),
127 deleted_uris: DashSet::new(),
128 workspace_files_dirty: AtomicBool::new(true),
129 lsp_workspace: Mutex::new(None),
130 php_version: Mutex::new(mir_analyzer::PhpVersion::LATEST),
131 psr4: Arc::new(ArcSwap::from_pointee(Psr4Map::empty())),
132 analysis_session: Mutex::new(None),
133 session_cache_dir: OnceLock::new(),
134 autoload_uris: std::sync::RwLock::new(Vec::new()),
135 index_ready: AtomicBool::new(false),
136 write_revision: AtomicU64::new(0),
137 reanalyze_cancel: Mutex::new(mir_analyzer::IndexCancel::new()),
138 warm_sweep_cancel: Mutex::new(mir_analyzer::IndexCancel::new()),
139 warm_sweeps_completed: AtomicU64::new(0),
140 interactive_reads: AtomicU64::new(0),
141 }
142 }
143
144 pub fn interactive_read_guard(&self) -> InteractiveReadGuard<'_> {
149 self.interactive_reads.fetch_add(1, Ordering::Relaxed);
150 InteractiveReadGuard(&self.interactive_reads)
151 }
152
153 pub fn settled_write_rev_guard(&self) -> (InteractiveReadGuard<'_>, u64) {
161 let guard = self.interactive_read_guard();
162 if !self.is_index_ready() {
165 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50);
166 let mut rev = self.write_rev();
167 loop {
168 std::thread::sleep(std::time::Duration::from_millis(2));
169 let now = self.write_rev();
170 let quiet = now == rev;
171 rev = now;
172 if quiet || std::time::Instant::now() >= deadline {
173 break;
174 }
175 }
176 }
177 (guard, self.write_rev())
178 }
179
180 pub fn yield_to_interactive_reads(&self) {
185 let mut waited = std::time::Duration::ZERO;
186 const STEP: std::time::Duration = std::time::Duration::from_millis(2);
187 const MAX: std::time::Duration = std::time::Duration::from_millis(500);
188 while self.interactive_reads.load(Ordering::Relaxed) > 0 && waited < MAX {
189 std::thread::sleep(STEP);
190 waited += STEP;
191 }
192 }
193
194 pub fn begin_reanalyze(&self) -> mir_analyzer::IndexCancel {
200 let fresh = mir_analyzer::IndexCancel::new();
201 let mut guard = self.reanalyze_cancel.lock().unwrap();
202 guard.cancel();
203 *guard = fresh.clone();
204 fresh
205 }
206
207 pub fn begin_warm_sweep(&self) -> mir_analyzer::IndexCancel {
212 let fresh = mir_analyzer::IndexCancel::new();
213 let mut guard = self.warm_sweep_cancel.lock().unwrap();
214 guard.cancel();
215 *guard = fresh.clone();
216 fresh
217 }
218
219 pub const WARM_SWEEP_MAX_FILES: usize = 16_384;
227
228 pub fn warm_analysis_sweep(&self, priority: &[Url], cancel: &mir_analyzer::IndexCancel) {
244 std::thread::scope(|s| {
249 let _ = std::thread::Builder::new()
250 .name("php-lsp-warm-sweep".into())
251 .stack_size(32 * 1024 * 1024)
252 .spawn_scoped(s, || self.warm_analysis_sweep_inner(priority, cancel))
253 .map(|h| h.join());
254 });
255 }
256
257 fn warm_analysis_sweep_inner(&self, priority: &[Url], cancel: &mir_analyzer::IndexCancel) {
258 const CHUNK: usize = 32;
259 let front: Vec<Arc<str>> = self.sweep_priority_files(priority);
260 let front_set: HashSet<&str> = front.iter().map(|f| f.as_ref()).collect();
261 let files: Vec<Arc<str>> = front
262 .iter()
263 .cloned()
264 .chain(
265 self.lsp_ws_files
266 .iter()
267 .filter(|e| !self.deleted_uris.contains(e.key()))
268 .map(|e| Arc::<str>::from(e.key().as_str()))
269 .filter(|f| !front_set.contains(f.as_ref())),
270 )
271 .take(Self::WARM_SWEEP_MAX_FILES)
272 .collect();
273 drop(front_set);
274 let session = self.analysis_session(self.workspace_php_version());
275 for chunk in files.chunks(CHUNK) {
276 if cancel.is_cancelled() {
277 return;
278 }
279 self.yield_to_interactive_reads();
280 let _ = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
284 session.reanalyze_files_cancellable(chunk, cancel)
285 }));
286 }
287 if !cancel.is_cancelled() {
288 self.warm_sweeps_completed.fetch_add(1, Ordering::Relaxed);
289 }
290 }
291
292 pub fn warm_sweeps_completed(&self) -> u64 {
294 self.warm_sweeps_completed.load(Ordering::Relaxed)
295 }
296
297 fn sweep_priority_files(&self, priority: &[Url]) -> Vec<Arc<str>> {
303 if priority.is_empty() {
304 return Vec::new();
305 }
306 let ws = self.get_workspace_index_salsa();
307 let mut out: Vec<Arc<str>> = Vec::new();
308 let mut seen: HashSet<Arc<str>> = HashSet::new();
309 let push = |uri: &Url, out: &mut Vec<Arc<str>>, seen: &mut HashSet<Arc<str>>| {
310 let f: Arc<str> = Arc::from(uri.as_str());
311 if seen.insert(Arc::clone(&f)) {
312 out.push(f);
313 }
314 };
315 for uri in priority {
316 push(uri, &mut out, &mut seen);
317 }
318 for uri in priority {
319 let Some(doc) = self.get_doc_salsa(uri) else {
320 continue;
321 };
322 for fqn in crate::navigation::references::collect_referenced_class_fqns(&doc) {
323 let short = crate::text::fqn_short_name(&fqn);
324 let Some(refs) = ws.classes_by_name.get(short) else {
325 continue;
326 };
327 for &r in refs {
328 if let Some((decl_uri, cls)) = ws.at(r)
329 && cls.fqn.trim_start_matches('\\') == fqn
330 {
331 push(decl_uri, &mut out, &mut seen);
332 }
333 }
334 }
335 }
336 out
337 }
338
339 pub fn mark_index_ready(&self) {
342 self.index_ready.store(true, Ordering::Release);
343 }
344
345 pub fn is_index_ready(&self) -> bool {
347 self.index_ready.load(Ordering::Acquire)
348 }
349
350 pub fn write_rev(&self) -> u64 {
355 self.write_revision.load(Ordering::Acquire)
356 }
357
358 pub fn set_session_cache_dir(&self, dir: std::path::PathBuf) {
362 let _ = self.session_cache_dir.set(dir);
363 }
364
365 pub fn set_autoload_uris(&self, uris: Vec<Url>) {
371 *self.autoload_uris.write().unwrap() = uris;
372 self.caches.evict_analysis_all();
373 }
374
375 pub fn analysis_session(
383 &self,
384 php_version: mir_analyzer::PhpVersion,
385 ) -> Arc<mir_analyzer::AnalysisSession> {
386 let mut guard = self.analysis_session.lock().unwrap();
387 if let Some((cached_ver, session)) = guard.as_ref()
388 && *cached_ver == php_version
389 {
390 return Arc::clone(session);
391 }
392 let resolver: Arc<dyn mir_analyzer::ClassResolver> = self.psr4.load_full();
396 let mut builder = mir_analyzer::AnalysisSession::new(php_version)
401 .with_class_resolver(resolver)
402 .without_reference_index();
403 if let Some(dir) = self.session_cache_dir.get() {
404 builder = builder.with_cache_dir(dir);
405 }
406 let session = Arc::new(builder);
407 *guard = Some((php_version, Arc::clone(&session)));
408 session
409 }
410
411 pub fn workspace_php_version(&self) -> mir_analyzer::PhpVersion {
413 *self.php_version.lock().unwrap()
414 }
415
416 pub fn class_subtype_urls(&self, class_fqn: &str) -> Vec<tower_lsp::lsp_types::Url> {
424 let session = self.analysis_session(self.workspace_php_version());
425 session
426 .subtype_files(class_fqn)
427 .into_iter()
428 .filter_map(|p| tower_lsp::lsp_types::Url::parse(&p).ok())
429 .collect()
430 }
431
432 pub fn psr4_arc(&self) -> Arc<ArcSwap<Psr4Map>> {
437 Arc::clone(&self.psr4)
438 }
439
440 fn input_durability(uri: &Url) -> salsa::Durability {
443 if uri.as_str().contains("/vendor/") {
444 salsa::Durability::HIGH
445 } else {
446 salsa::Durability::LOW
447 }
448 }
449
450 fn lsp_ws_file(&self, uri: &Url) -> Option<LspWsFile> {
452 if self.deleted_uris.contains(uri) {
453 return None;
454 }
455 self.lsp_ws_files.get(uri).map(|e| *e)
456 }
457
458 fn snapshot_mir_query<R>(&self, f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R) -> R {
467 use std::panic::AssertUnwindSafe;
468 let _interactive = self.interactive_read_guard();
469 let session = self.analysis_session(self.workspace_php_version());
470 loop {
475 let db = session.snapshot_db();
476 match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
477 Ok(r) => return r,
478 Err(_) => drop(db),
479 }
480 }
481 }
482
483 fn try_snapshot_mir_query<R>(
489 &self,
490 attempts: usize,
491 f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R,
492 ) -> Option<R> {
493 use std::panic::AssertUnwindSafe;
494 let _interactive = self.interactive_read_guard();
495 let session = self.analysis_session(self.workspace_php_version());
496 for _ in 0..attempts {
497 let db = session.snapshot_db();
498 match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
499 Ok(r) => return Some(r),
500 Err(_) => drop(db),
501 }
502 }
503 None
504 }
505
506 pub fn mirror_text(&self, uri: &Url, text: &str) {
511 if let Some(cached) = self.caches.text_cache.get(uri)
517 && **cached == *text
518 && !self.deleted_uris.contains(uri)
519 && self.lsp_ws_files.contains_key(uri)
520 {
521 return;
522 }
523 self.mirror_text_arc(uri, Arc::from(text))
524 }
525
526 pub fn mirror_text_arc(&self, uri: &Url, text_arc: Arc<str>) {
533 let dur = Self::input_durability(uri);
534 let path: Arc<str> = Arc::from(uri.as_str());
535 let session = self.analysis_session(self.workspace_php_version());
536 if let Some(wf) = self.lsp_ws_files.get(uri).map(|e| *e) {
537 self.deleted_uris.remove(uri);
538 if let Some(cached) = self.caches.text_cache.get(uri)
541 && **cached == *text_arc
542 {
543 return;
544 }
545 session.with_db_mut(|db| {
546 let sf = wf.source(db);
547 sf.set_text(db).with_durability(dur).to(text_arc.clone());
548 if wf.cached_index(db).is_some() {
551 wf.set_cached_index(db).to(None);
552 }
553 });
554 self.caches.text_cache.insert(uri.clone(), text_arc);
555 self.caches.evict_analysis(uri);
558 self.write_revision.fetch_add(1, Ordering::Release);
559 } else {
560 let wf = session.with_db_mut(|db| {
561 let sf = db.upsert_source_file_with_durability(path, text_arc.clone(), dur);
562 LspWsFile::new(db, sf, None)
563 });
564 self.lsp_ws_files.insert(uri.clone(), wf);
565 self.caches.text_cache.insert(uri.clone(), text_arc);
566 self.workspace_files_dirty.store(true, Ordering::Release);
567 self.write_revision.fetch_add(1, Ordering::Release);
568 }
569 }
570
571 #[cfg(test)]
573 pub fn source_file(&self, uri: &Url) -> Option<LspWsFile> {
574 if self.deleted_uris.contains(uri) {
575 return None;
576 }
577 self.lsp_ws_files.get(uri).map(|e| *e)
578 }
579
580 pub fn seed_cached_index(&self, uri: &Url, index: Arc<FileIndex>) -> bool {
593 let Some(wf) = self.lsp_ws_file(uri) else {
594 return false;
595 };
596 let session = self.analysis_session(self.workspace_php_version());
597 session.with_db_mut(|db| wf.set_cached_index(db).to(Some(index)));
598 true
599 }
600
601 pub fn evict_token_cache(&self, uri: &Url) {
605 self.caches.evict_tokens(uri);
606 }
607
608 #[cfg(test)]
614 pub fn source_files_len(&self) -> usize {
615 self.lsp_ws_files.len()
616 }
617
618 #[cfg(test)]
619 pub fn snapshot_query_file_index(
620 &self,
621 uri: &Url,
622 ) -> Option<crate::index::file_index::FileIndex> {
623 let wf = self.lsp_ws_file(uri)?;
624 Some(
625 self.snapshot_mir_query(move |db| {
626 (*crate::db::mir_queries::file_index(db, wf).0).clone()
627 }),
628 )
629 }
630
631 pub fn ingest(&self, uri: Url, text: &str) {
636 self.mirror_text(&uri, text);
637 }
638
639 pub fn ingest_from_doc(&self, uri: Url, doc: &ParsedDoc) {
648 self.mirror_text_arc(&uri, doc.source_arc());
649 }
650
651 pub fn remove(&self, uri: &Url) {
652 self.caches.evict(uri);
653 self.deleted_uris.insert(uri.clone());
658 self.workspace_files_dirty.store(true, Ordering::Release);
659 self.sync_workspace_files();
662 let guard = self.analysis_session.lock().unwrap();
666 if let Some((_, session)) = guard.as_ref() {
667 session.invalidate_file(uri.as_str());
668 }
669 self.write_revision.fetch_add(1, Ordering::Release);
670 }
671
672 pub fn get_doc_salsa(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
685 self.get_parsed_cached(uri)
686 }
687
688 pub fn get_index_salsa(&self, uri: &Url) -> Option<Arc<FileIndex>> {
690 let wf = self.lsp_ws_file(uri)?;
691 Some(
692 self.snapshot_mir_query(move |db| crate::db::mir_queries::file_index(db, wf).0.clone()),
693 )
694 }
695
696 pub fn get_symbol_map_salsa(
699 &self,
700 uri: &Url,
701 ) -> Option<Arc<crate::types::symbol_map::SymbolMap>> {
702 let wf = self.lsp_ws_file(uri)?;
704 Some(self.snapshot_mir_query(move |db| {
705 let sf = wf.source(db);
706 crate::db::mir_queries::symbol_map(db, sf).0.clone()
707 }))
708 }
709
710 pub fn other_symbol_maps(
712 &self,
713 uri: &Url,
714 open_urls: &[Url],
715 ) -> Vec<(Url, Arc<crate::types::symbol_map::SymbolMap>)> {
716 open_urls
717 .iter()
718 .filter(|u| *u != uri)
719 .filter_map(|u| self.get_symbol_map_salsa(u).map(|m| (u.clone(), m)))
720 .collect()
721 }
722
723 fn get_parsed_cached(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
730 if let Some(current_text) = self.caches.text_cache.get(uri)
731 && let Some(entry) = self.caches.parsed_cache.get(uri)
732 && Arc::ptr_eq(&*current_text, &entry.0)
733 {
734 let doc = entry.1.clone();
735 drop(entry);
736 self.caches.touch(uri);
737 return Some(doc);
738 }
739
740 let wf = self.lsp_ws_file(uri)?;
742 self.caches.bump_parse_count();
743 let (text, doc) = self.snapshot_mir_query(move |db| {
744 let sf = wf.source(db);
745 let text = sf.text(db);
746 let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
747 (text, doc)
748 });
749 self.caches.insert_parsed(uri.clone(), text, doc.clone());
750 Some(doc)
751 }
752
753 pub fn get_doc_snapshot_or_stale(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
761 if let Some(current_text) = self.caches.text_cache.get(uri)
762 && let Some(entry) = self.caches.parsed_cache.get(uri)
763 && Arc::ptr_eq(&*current_text, &entry.0)
764 {
765 let doc = entry.1.clone();
766 drop(entry);
767 self.caches.touch(uri);
768 return Some(doc);
769 }
770
771 if let Some(wf) = self.lsp_ws_file(uri)
772 && let Some((text, doc)) = self.try_snapshot_mir_query(3, move |db| {
773 let sf = wf.source(db);
774 let text = sf.text(db);
775 let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
776 (text, doc)
777 })
778 {
779 self.caches.bump_parse_count();
780 self.caches.insert_parsed(uri.clone(), text, doc.clone());
781 return Some(doc);
782 }
783
784 self.caches.parsed_cache.get(uri).map(|e| e.1.clone())
785 }
786
787 pub fn sync_workspace_files(&self) {
792 if !self.workspace_files_dirty.swap(false, Ordering::AcqRel) {
795 return;
796 }
797
798 let mut entries: Vec<(Arc<str>, LspWsFile)> = self
800 .lsp_ws_files
801 .iter()
802 .filter(|e| !self.deleted_uris.contains(e.key()))
803 .map(|e| (Arc::<str>::from(e.key().as_str()), *e.value()))
804 .collect();
805 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
806 let files: Arc<[LspWsFile]> = entries.iter().map(|(_, wf)| *wf).collect();
807
808 let session = self.analysis_session(self.workspace_php_version());
809 let mut guard = self.lsp_workspace.lock().unwrap();
810 session.with_db_mut(|db| match *guard {
811 Some(ws) => {
812 ws.set_files(db).to(files);
813 }
814 None => *guard = Some(LspWorkspace::new(db, files)),
815 });
816 }
817
818 pub fn mark_workspace_files_dirty(&self) {
822 self.workspace_files_dirty.store(true, Ordering::Release);
823 }
824
825 pub fn set_php_version(&self, version: mir_analyzer::PhpVersion) {
830 {
831 let mut guard = self.php_version.lock().unwrap();
832 if *guard == version {
833 return;
834 }
835 *guard = version;
836 }
837 self.lsp_ws_files.clear();
842 *self.lsp_workspace.lock().unwrap() = None;
843 self.workspace_files_dirty.store(true, Ordering::Release);
844 self.caches.evict_analysis_all();
846 }
847
848 pub fn session_references_to(
862 &self,
863 symbol: &mir_analyzer::Name,
864 files: &[Arc<str>],
865 cancel_rev: Option<u64>,
866 ) -> Vec<(Arc<str>, u32, u32, u32)> {
867 let php_version = self.workspace_php_version();
868 let stale =
874 || cancel_rev.is_some_and(|rev| self.write_revision.load(Ordering::Acquire) != rev);
875 let raw = loop {
879 let session = self.analysis_session(php_version);
880 match salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
881 session.references_to_in_files_cancellable(symbol, files, &stale)
882 })) {
883 Ok(Some(refs)) => break refs,
884 Ok(None) => {
889 std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
890 }
891 Err(_) if stale() => {
892 std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
893 }
894 Err(_) => {}
898 }
899 };
900 raw.into_iter()
901 .map(|(file, range)| {
902 let line = range.start.line.saturating_sub(1);
904 let col_start = range.start.column;
905 let col_end = range.end.column;
906 (file, line, col_start, col_end)
907 })
908 .collect()
909 }
910
911 pub fn method_reference_scope(&self, owner_fqn: &str, method: &str) -> Option<Vec<Url>> {
935 use crate::index::file_index::{ClassKind, Visibility};
936
937 let ws = self.get_workspace_index_salsa();
938 let owner_fqn = owner_fqn.trim_start_matches('\\');
939 let owner_short = crate::text::fqn_short_name(owner_fqn);
940
941 let (decl_uri, decl_class) = ws
942 .classes_by_name
943 .get(owner_short)?
944 .iter()
945 .filter_map(|&r| ws.at(r))
946 .find(|(_, cls)| cls.fqn.trim_start_matches('\\') == owner_fqn)?;
947
948 if !matches!(decl_class.kind, ClassKind::Class | ClassKind::Enum)
951 || !decl_class.traits.is_empty()
952 || !decl_class.mixins.is_empty()
953 {
954 return None;
955 }
956
957 let vis = decl_class
958 .methods
959 .iter()
960 .find(|m| m.name.eq_ignore_ascii_case(method))?
961 .visibility;
962
963 match vis {
964 Visibility::Private => Some(vec![decl_uri.clone()]),
965 Visibility::Protected => {
966 if !self.is_index_ready() {
967 return None;
968 }
969 let session = self.analysis_session(self.workspace_php_version());
974 let mut files: std::collections::HashSet<Url> = session
975 .subtype_files(owner_fqn)
976 .into_iter()
977 .filter_map(|p| Url::parse(&p).ok())
978 .collect();
979 files.insert(decl_uri.clone());
980 Some(files.into_iter().collect())
981 }
982 Visibility::Public => None,
983 }
984 }
985
986 pub fn get_workspace_index_salsa(&self) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
996 self.sync_workspace_files();
997 let ws = *self.lsp_workspace.lock().unwrap();
998 let Some(ws) = ws else {
999 return Arc::new(crate::db::workspace_index::WorkspaceIndexData {
1000 files: Vec::new(),
1001 classes_by_name: std::collections::HashMap::new(),
1002 subtypes_of: std::collections::HashMap::new(),
1003 decls_by_name: std::collections::HashMap::new(),
1004 classes_by_lowercase_name: Vec::new(),
1005 });
1006 };
1007 self.snapshot_mir_query(move |db| crate::db::mir_queries::workspace_index(db, ws).0.clone())
1008 }
1009
1010 pub fn parse_count(&self) -> u64 {
1014 self.caches.parse_count()
1015 }
1016
1017 pub fn ref_index_lock_count(&self) -> u64 {
1022 self.analysis_session(self.workspace_php_version())
1023 .ref_index_lock_count()
1024 }
1025
1026 pub fn source_text(&self, uri: &Url) -> Option<Arc<str>> {
1030 self.caches.text_cache.get(uri).map(|e| Arc::clone(&e))
1031 }
1032
1033 pub fn store_token_cache(&self, uri: &Url, result_id: String, tokens: Arc<Vec<SemanticToken>>) {
1036 self.caches.store_token(uri, result_id, tokens);
1037 }
1038
1039 pub fn get_token_cache(&self, uri: &Url, result_id: &str) -> Option<Arc<Vec<SemanticToken>>> {
1041 self.caches.get_token(uri, result_id)
1042 }
1043
1044 #[tracing::instrument(skip_all)]
1050 pub fn get_semantic_issues_salsa(&self, uri: &Url) -> Option<Arc<[mir_issues::Issue]>> {
1051 let analysis = self.cached_analysis(uri)?;
1052 let file: Arc<str> = Arc::from(uri.as_str());
1053 let class_issues = {
1059 let _s = tracing::debug_span!("session.class_issues_for").entered();
1060 loop {
1062 let session = self.analysis_session(self.workspace_php_version());
1063 if let Ok(issues) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1064 session.class_issues(std::slice::from_ref(&file))
1065 })) {
1066 break issues;
1067 }
1068 }
1069 };
1070 let combined: Vec<mir_issues::Issue> = analysis
1071 .issues
1072 .iter()
1073 .cloned()
1074 .chain(class_issues)
1075 .filter(|i| !i.suppressed)
1076 .collect();
1077 Some(Arc::from(combined))
1078 }
1079
1080 pub fn cached_type_map(
1095 &self,
1096 uri: &Url,
1097 doc: &crate::document::ast::ParsedDoc,
1098 meta: Option<&crate::lang::phpstorm_meta::PhpStormMeta>,
1099 ) -> Arc<crate::types::type_map::TypeMap> {
1100 let source = doc.source_arc();
1101 let meta_key = meta.map_or(0usize, |m| std::ptr::from_ref(m) as usize);
1102 if let Some(entry) = self.caches.type_map_cache.get(uri)
1103 && Arc::ptr_eq(&entry.0, &source)
1104 && entry.1 == meta_key
1105 {
1106 let map = Arc::clone(&entry.2);
1107 drop(entry);
1108 self.caches.touch(uri);
1109 return map;
1110 }
1111 let map = Arc::new(crate::types::type_map::TypeMap::from_doc_with_meta(
1112 doc, meta,
1113 ));
1114 self.caches.shed_stale(
1115 &self.caches.type_map_cache,
1116 crate::document::cache_registry::TYPE_MAP_CACHE_CAP,
1117 );
1118 self.caches.touch(uri);
1119 self.caches
1120 .type_map_cache
1121 .insert(uri.clone(), (source, meta_key, Arc::clone(&map)));
1122 map
1123 }
1124
1125 pub fn cached_analysis_if_fresh(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1131 let doc = self.get_doc_salsa(uri)?;
1132 let source = doc.source_arc();
1133 let cur_ver = self.caches.decl_version();
1134 let analysis = {
1135 let entry = self.caches.analysis_cache.get(uri)?;
1136 (Arc::ptr_eq(&entry.0, &source) && entry.1 == cur_ver).then(|| Arc::clone(&entry.2))?
1137 };
1138 self.caches.touch(uri);
1139 Some(analysis)
1140 }
1141
1142 pub fn cached_analysis(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1143 self.cached_analysis_cancellable(uri, &|| false)
1144 }
1145
1146 #[tracing::instrument(skip_all)]
1152 pub fn cached_analysis_cancellable(
1153 &self,
1154 uri: &Url,
1155 should_cancel: &(dyn Fn() -> bool + Sync),
1156 ) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1157 let doc = self.get_doc_salsa(uri)?;
1159 let source = doc.source_arc();
1160
1161 let cur_ver = self.caches.decl_version();
1162 if let Some(entry) = self.caches.analysis_cache.get(uri)
1163 && Arc::ptr_eq(&entry.0, &source)
1164 && entry.1 == cur_ver
1165 {
1166 let analysis = Arc::clone(&entry.2);
1167 drop(entry);
1168 self.caches.touch(uri);
1169 return Some(analysis);
1170 }
1171
1172 let php_version = self.workspace_php_version();
1173 let session = self.analysis_session(php_version);
1174 let file: Arc<str> = Arc::from(uri.as_str());
1175
1176 let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
1177 let owned_program = if let Some(cached) = self.caches.owned_program_cache.get(uri)
1178 && Arc::ptr_eq(&cached.0, &source)
1179 {
1180 Arc::clone(&cached.1)
1181 } else {
1182 let prog = Arc::new(php_ast::owned::to_owned_program(doc.program()));
1183 self.caches.shed_stale(
1184 &self.caches.owned_program_cache,
1185 crate::document::cache_registry::OWNED_PROGRAM_CACHE_CAP,
1186 );
1187 self.caches
1188 .owned_program_cache
1189 .insert(uri.clone(), (Arc::clone(&source), Arc::clone(&prog)));
1190 prog
1191 };
1192
1193 let autoload_texts: Vec<(Arc<str>, Arc<str>)> = {
1195 let autoload_uris = self.autoload_uris.read().unwrap().clone();
1196 autoload_uris
1197 .iter()
1198 .filter_map(|auri| {
1199 self.caches
1200 .text_cache
1201 .get(auri)
1202 .map(|t| (Arc::from(auri.as_str()), Arc::clone(&*t)))
1203 })
1204 .collect()
1205 };
1206 let class_fqns = crate::references::collect_referenced_class_fqns(&doc);
1208
1209 let _interactive = self.interactive_read_guard();
1211 let analysis = loop {
1212 let attempt = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1213 session.ingest_file(file.clone(), source.clone());
1214 for (afile, atext) in &autoload_texts {
1215 session.ingest_file(afile.clone(), atext.clone());
1216 }
1217 for fqcn in &class_fqns {
1218 let _ = session.load_class(fqcn);
1219 }
1220 let analyzer = mir_analyzer::FileAnalyzer::new(&session);
1221 analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map)
1222 }));
1223 match attempt {
1224 Ok(a) => break Arc::new(a),
1225 Err(_) => {
1226 let text_changed = self
1232 .caches
1233 .text_cache
1234 .get(uri)
1235 .is_none_or(|t| !Arc::ptr_eq(&t, &source));
1236 if text_changed || should_cancel() {
1237 return None;
1238 }
1239 }
1240 }
1241 };
1242 let new_index = self.get_index_salsa(uri);
1248 let old_fp = self
1249 .caches
1250 .decl_fingerprints
1251 .get(uri)
1252 .map(|e| Arc::clone(&*e));
1253 let decl_changed = match (&old_fp, &new_index) {
1254 (Some(old), Some(new)) => **old != **new,
1255 (None, Some(new)) => !new.declares_nothing(),
1259 _ => false,
1260 };
1261 if decl_changed {
1262 if let Some(idx) = new_index {
1263 self.caches.decl_fingerprints.insert(uri.clone(), idx);
1264 }
1265 self.caches.bump_decl_version();
1266 session.bump_prepare_generation();
1271 }
1272 let ver = cur_ver + u64::from(decl_changed);
1278 self.caches.shed_stale(
1279 &self.caches.analysis_cache,
1280 crate::document::cache_registry::ANALYSIS_CACHE_CAP,
1281 );
1282 self.caches.touch(uri);
1283 self.caches
1284 .analysis_cache
1285 .insert(uri.clone(), (source, ver, Arc::clone(&analysis)));
1286 Some(analysis)
1287 }
1288
1289 pub fn docs_for(&self, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
1294 open_urls
1295 .iter()
1296 .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
1297 .collect()
1298 }
1299
1300 pub fn doc_with_others(
1303 &self,
1304 uri: &Url,
1305 doc: Arc<ParsedDoc>,
1306 open_urls: &[Url],
1307 ) -> Vec<(Url, Arc<ParsedDoc>)> {
1308 let mut result = vec![(uri.clone(), doc)];
1309 result.extend(self.other_docs(uri, open_urls));
1310 result
1311 }
1312
1313 pub fn other_docs(&self, uri: &Url, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
1315 open_urls
1316 .iter()
1317 .filter(|u| *u != uri)
1318 .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
1319 .collect()
1320 }
1321
1322 pub fn all_indexes(&self) -> Vec<(Url, Arc<FileIndex>)> {
1324 self.get_workspace_index_salsa().files.clone()
1325 }
1326
1327 pub fn cache_vendor_index(&self, uri: Url, index: Arc<FileIndex>) {
1331 self.caches.vendor_index_cache.insert(uri, index);
1332 }
1333
1334 pub fn get_vendor_index(&self, uri: &Url) -> Option<Arc<FileIndex>> {
1336 self.caches
1337 .vendor_index_cache
1338 .get(uri)
1339 .map(|e| Arc::clone(&*e))
1340 }
1341
1342 pub fn other_indexes(&self, uri: &Url) -> Vec<(Url, Arc<FileIndex>)> {
1344 self.get_workspace_index_salsa()
1345 .files
1346 .iter()
1347 .filter(|(u, _)| u != uri)
1348 .cloned()
1349 .collect()
1350 }
1351
1352 pub fn all_docs_for_scan(&self) -> Vec<(Url, Arc<ParsedDoc>)> {
1356 let urls: Vec<Url> = self
1357 .lsp_ws_files
1358 .iter()
1359 .filter(|e| !self.deleted_uris.contains(e.key()))
1360 .map(|e| e.key().clone())
1361 .collect();
1362 urls.into_iter()
1363 .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
1364 .collect()
1365 }
1366
1367 pub fn candidate_urls_for(&self, word: &str) -> Vec<Url> {
1378 use rayon::prelude::*;
1379 let urls: Vec<Url> = self
1380 .lsp_ws_files
1381 .iter()
1382 .filter(|e| !self.deleted_uris.contains(e.key()))
1383 .map(|e| e.key().clone())
1384 .collect();
1385 let finder = memchr::memmem::Finder::new(word.as_bytes());
1386 let wlen = word.len();
1387 urls.into_par_iter()
1388 .filter(|u| match self.caches.text_cache.get(u) {
1389 Some(src) => mentions_identifier(src.as_bytes(), &finder, wlen),
1390 None => true,
1391 })
1392 .collect()
1393 }
1394
1395 pub fn candidate_docs_for(&self, word: &str) -> Vec<(Url, Arc<ParsedDoc>)> {
1405 self.candidate_urls_for(word)
1406 .into_iter()
1407 .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
1408 .collect()
1409 }
1410}
1411
1412fn mentions_identifier(hay: &[u8], finder: &memchr::memmem::Finder, wlen: usize) -> bool {
1418 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1419 let mut from = 0;
1420 while let Some(rel) = finder.find(&hay[from..]) {
1421 let idx = from + rel;
1422 let before_ok = idx == 0 || !is_ident(hay[idx - 1]);
1423 let end = idx + wlen;
1424 let after_ok = end >= hay.len() || !is_ident(hay[end]);
1425 if before_ok && after_ok {
1426 return true;
1427 }
1428 from = idx + 1;
1429 }
1430 false
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435 use super::*;
1436
1437 fn uri(path: &str) -> Url {
1438 Url::parse(&format!("file://{path}")).unwrap()
1439 }
1440
1441 #[test]
1442 fn mentions_identifier_respects_word_boundaries() {
1443 let m = |hay: &str, word: &str| {
1444 let f = memchr::memmem::Finder::new(word.as_bytes());
1445 mentions_identifier(hay.as_bytes(), &f, word.len())
1446 };
1447 assert!(m("$this->save();", "save"));
1448 assert!(m("function save() {}", "save"));
1449 assert!(m("save", "save"));
1450 assert!(!m("$this->saveAll();", "save"));
1452 assert!(!m("return $unsaved;", "save"));
1453 assert!(!m("class Saver {}", "save"));
1454 assert!(!m("no occurrence here", "save"));
1455 }
1456
1457 fn open(store: &DocumentStore, u: Url, text: String) {
1462 store.mirror_text(&u, &text);
1463 }
1464
1465 #[test]
1470 fn scan_writers_yield_while_interactive_reads_are_in_flight() {
1471 let store = Arc::new(DocumentStore::new());
1472
1473 let t = std::time::Instant::now();
1475 store.yield_to_interactive_reads();
1476 assert!(t.elapsed() < std::time::Duration::from_millis(50));
1477
1478 let guard = store.interactive_read_guard();
1480 let waiter = {
1481 let store = Arc::clone(&store);
1482 std::thread::spawn(move || {
1483 let t = std::time::Instant::now();
1484 store.yield_to_interactive_reads();
1485 t.elapsed()
1486 })
1487 };
1488 std::thread::sleep(std::time::Duration::from_millis(60));
1489 drop(guard);
1490 let waited = waiter.join().unwrap();
1491 assert!(
1492 waited >= std::time::Duration::from_millis(40),
1493 "writer should have paused while the read guard was held (waited {waited:?})"
1494 );
1495 let g1 = store.interactive_read_guard();
1497 let g2 = store.interactive_read_guard();
1498 drop(g1);
1499 let waiter = {
1500 let store = Arc::clone(&store);
1501 std::thread::spawn(move || {
1502 let t = std::time::Instant::now();
1503 store.yield_to_interactive_reads();
1504 t.elapsed()
1505 })
1506 };
1507 std::thread::sleep(std::time::Duration::from_millis(30));
1508 drop(g2);
1509 assert!(waiter.join().unwrap() >= std::time::Duration::from_millis(20));
1510 store.yield_to_interactive_reads(); }
1512
1513 #[test]
1514 fn index_registers_file_in_salsa() {
1515 let store = DocumentStore::new();
1516 store.ingest(uri("/lib.php"), "<?php\nfunction lib_fn() {}");
1517 let idx = store.get_index_salsa(&uri("/lib.php")).unwrap();
1518 assert_eq!(idx.functions.len(), 1);
1519 assert_eq!(&*idx.functions[0].name, "lib_fn");
1520 }
1521
1522 #[test]
1523 fn remove_hides_file_from_index() {
1524 let store = DocumentStore::new();
1525 let u = uri("/lib.php");
1526 store.ingest(u.clone(), "<?php");
1527 store.remove(&u);
1528 assert!(store.get_index_salsa(&u).is_none());
1529 }
1530
1531 #[test]
1532 fn remove_and_reopen_reuses_source_file_handle() {
1533 let store = DocumentStore::new();
1534 let u = uri("/lib.php");
1535 store.ingest(u.clone(), "<?php");
1536 let ft_before = store.source_file(&u).unwrap();
1537 store.remove(&u);
1538 assert!(
1539 store.source_file(&u).is_none(),
1540 "deleted file should be hidden"
1541 );
1542 store.mirror_text(&u, "<?php");
1543 let ft_after = store.source_file(&u).unwrap();
1544 assert!(
1545 ft_before == ft_after,
1546 "reopen must reuse the same FileText handle"
1547 );
1548 }
1549
1550 #[test]
1551 fn delete_reopen_churn_does_not_amplify_salsa_inputs() {
1552 let store = DocumentStore::new();
1553 let uris: Vec<Url> = (0..20).map(|i| uri(&format!("/churn/f{i}.php"))).collect();
1554 for u in &uris {
1555 store.ingest(u.clone(), "<?php class A {}");
1556 }
1557 let count_before = store.source_files_len();
1558 for _ in 0..10 {
1559 for u in &uris {
1560 store.remove(u);
1561 }
1562 for u in &uris {
1563 store.ingest(u.clone(), "<?php class A {}");
1564 }
1565 }
1566 assert_eq!(
1567 store.source_files_len(),
1568 count_before,
1569 "delete-reopen cycles must not create new salsa inputs (L1-B regression guard)"
1570 );
1571 }
1572
1573 #[test]
1574 fn warm_analysis_sweep_preserves_reference_results() {
1575 let store = DocumentStore::new();
1576 store.ingest(
1577 uri("/svc.php"),
1578 "<?php\nnamespace App;\nclass Svc { public function run(): void {} }",
1579 );
1580 store.ingest(
1581 uri("/caller.php"),
1582 "<?php\nnamespace App;\nclass Caller {\n private Svc $s;\n public function go(): void { $this->s->run(); }\n}",
1583 );
1584 let sym = mir_analyzer::Name::method("App\\Svc", "run");
1585 let files: Vec<Arc<str>> = [uri("/svc.php"), uri("/caller.php")]
1586 .iter()
1587 .map(|u| Arc::from(u.as_str()))
1588 .collect();
1589 let cold = store.session_references_to(&sym, &files, None);
1590 assert_eq!(cold.len(), 1, "caller.php references Svc::run once");
1591
1592 let cancel = store.begin_warm_sweep();
1593 store.warm_analysis_sweep(&[], &cancel);
1594 let warm = store.session_references_to(&sym, &files, None);
1595 assert_eq!(cold, warm, "sweep must not change reference results");
1596
1597 store.ingest(
1599 uri("/caller.php"),
1600 "<?php\nnamespace App;\nclass Caller {\n private Svc $s;\n public function go(): void { $this->s->run(); $this->s->run(); }\n}",
1601 );
1602 let cancel = store.begin_warm_sweep();
1603 store.warm_analysis_sweep(&[], &cancel);
1604 let after_edit = store.session_references_to(&sym, &files, None);
1605 assert_eq!(after_edit.len(), 2, "re-sweep must see the new reference");
1606 }
1607
1608 #[test]
1609 fn sweep_priority_puts_open_files_and_their_dependencies_first() {
1610 let store = DocumentStore::new();
1611 store.ingest(
1612 uri("/a.php"),
1613 "<?php\nnamespace App;\nclass A { public function go(B $b): void {} }",
1614 );
1615 store.ingest(uri("/b.php"), "<?php\nnamespace App;\nclass B {}");
1616 store.ingest(uri("/unrelated.php"), "<?php\nnamespace App;\nclass C {}");
1617 store.mark_index_ready();
1618
1619 let front = store.sweep_priority_files(&[uri("/a.php")]);
1620 let front: Vec<&str> = front.iter().map(|f| f.as_ref()).collect();
1621 assert_eq!(
1622 front,
1623 vec![uri("/a.php").as_str(), uri("/b.php").as_str()],
1624 "open file first, then the files declaring its referenced classes"
1625 );
1626 }
1627
1628 #[test]
1629 fn warm_analysis_sweep_stops_on_cancel() {
1630 let store = DocumentStore::new();
1631 for i in 0..40 {
1632 store.ingest(
1633 uri(&format!("/f{i}.php")),
1634 "<?php\nclass A { public function m(): int { return 1; } }",
1635 );
1636 }
1637 let cancel = store.begin_warm_sweep();
1638 cancel.cancel();
1639 store.warm_analysis_sweep(&[], &cancel);
1641 let old = store.begin_warm_sweep();
1643 let _new = store.begin_warm_sweep();
1644 assert!(old.is_cancelled());
1645 }
1646
1647 #[test]
1648 fn all_indexes_includes_every_mirrored_file() {
1649 let store = DocumentStore::new();
1650 open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1651 store.ingest(uri("/b.php"), "<?php\nfunction b() {}");
1652 assert_eq!(store.all_indexes().len(), 2);
1653 }
1654
1655 #[test]
1656 fn other_indexes_excludes_current_uri() {
1657 let store = DocumentStore::new();
1658 open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1659 open(&store, uri("/b.php"), "<?php\nfunction b() {}".to_string());
1660 assert_eq!(store.other_indexes(&uri("/a.php")).len(), 1);
1661 }
1662
1663 #[test]
1664 fn other_docs_excludes_current_uri() {
1665 let store = DocumentStore::new();
1666 let ua = uri("/a.php");
1667 let ub = uri("/b.php");
1668 open(&store, ua.clone(), "<?php\nfunction a() {}".to_string());
1669 open(&store, ub.clone(), "<?php\nfunction b() {}".to_string());
1670 let open_urls = vec![ua.clone(), ub];
1671 assert_eq!(store.other_docs(&ua, &open_urls).len(), 1);
1672 }
1673
1674 #[test]
1675 fn evict_token_cache_removes_entry() {
1676 let store = DocumentStore::new();
1677 let u = uri("/a.php");
1678 open(&store, u.clone(), "<?php".to_string());
1679 store.store_token_cache(&u, "id1".to_string(), Arc::new(vec![]));
1680 assert!(store.get_token_cache(&u, "id1").is_some());
1681 store.evict_token_cache(&u);
1682 assert!(store.get_token_cache(&u, "id1").is_none());
1683 }
1684
1685 #[test]
1686 fn vendor_index_cache_evicted_on_remove() {
1687 let store = DocumentStore::new();
1688 let u = uri("/vendor/acme/lib.php");
1689 store.ingest(u.clone(), "<?php\nclass Lib {}");
1690 let idx = store.get_index_salsa(&u).unwrap();
1691 store.cache_vendor_index(u.clone(), idx.clone());
1692 assert!(store.get_vendor_index(&u).is_some());
1693 store.remove(&u);
1694 assert!(store.get_vendor_index(&u).is_none());
1695 }
1696
1697 #[test]
1698 fn index_populates_file_index_with_symbols() {
1699 let store = DocumentStore::new();
1700 store.ingest(uri("/a.php"), "<?php\nfunction hello() {}");
1701 let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1702 assert_eq!(idx.functions.len(), 1);
1703 assert_eq!(&*idx.functions[0].name, "hello");
1704 }
1705
1706 #[test]
1707 fn open_populates_file_index_with_symbols() {
1708 let store = DocumentStore::new();
1709 open(&store, uri("/a.php"), "<?php\nclass Foo {}".to_string());
1710 let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1711 assert_eq!(idx.classes.len(), 1);
1712 assert_eq!(&*idx.classes[0].name, "Foo");
1713 }
1714
1715 fn names_of(idx: &FileIndex) -> Vec<String> {
1722 let mut out: Vec<String> = idx.classes.iter().map(|c| c.name.to_string()).collect();
1723 out.extend(idx.functions.iter().map(|f| f.name.to_string()));
1724 out.sort();
1725 out
1726 }
1727
1728 fn salsa_index_names(store: &DocumentStore, url: &Url) -> Vec<String> {
1729 store
1730 .snapshot_query_file_index(url)
1731 .map(|idx| names_of(&idx))
1732 .unwrap_or_default()
1733 }
1734
1735 #[test]
1736 fn mirror_tracks_repeated_edits() {
1737 let store = DocumentStore::new();
1738 let u = uri("/mirror.php");
1739
1740 open(&store, u.clone(), "<?php\nclass A {}".to_string());
1741 assert_eq!(salsa_index_names(&store, &u), vec!["A".to_string()]);
1742
1743 open(
1744 &store,
1745 u.clone(),
1746 "<?php\nclass A {}\nclass B {}".to_string(),
1747 );
1748 assert_eq!(
1749 salsa_index_names(&store, &u),
1750 vec!["A".to_string(), "B".to_string()]
1751 );
1752
1753 open(&store, u.clone(), "<?php\nfunction greet() {}".to_string());
1754 assert_eq!(salsa_index_names(&store, &u), vec!["greet".to_string()]);
1755 }
1756
1757 #[test]
1758 fn mirror_tracks_ingest_and_ingest_from_doc() {
1759 let store = DocumentStore::new();
1760
1761 let u1 = uri("/bg1.php");
1763 store.ingest(u1.clone(), "<?php\nclass Bg1 {}");
1764 assert_eq!(salsa_index_names(&store, &u1), vec!["Bg1".to_string()]);
1765
1766 let u2 = uri("/bg2.php");
1768 let doc = crate::analysis::diagnostics::parse_document_no_diags(
1769 "<?php\nclass Bg2 {}\nfunction f() {}",
1770 );
1771 store.ingest_from_doc(u2.clone(), &doc);
1772 assert_eq!(
1773 salsa_index_names(&store, &u2),
1774 vec!["Bg2".to_string(), "f".to_string()]
1775 );
1776 }
1777
1778 #[test]
1789 fn seed_cached_index_noops_for_unknown_uri() {
1790 let store = DocumentStore::new();
1791 let u = uri("/never_mirrored.php");
1792 let index = Arc::new(crate::index::file_index::FileIndex::default());
1793 assert!(!store.seed_cached_index(&u, index));
1794 }
1795
1796 #[test]
1798 fn parsed_cache_stays_bounded_under_many_inserts() {
1799 let store = DocumentStore::new();
1800 use crate::document::cache_registry::PARSED_CACHE_CAP;
1801 let overflow = PARSED_CACHE_CAP + 100;
1802 for i in 0..overflow {
1803 let u = uri(&format!("/cap/file{i}.php"));
1804 store.ingest(u.clone(), "<?php\nclass A {}");
1805 let _ = store.get_doc_salsa(&u);
1807 }
1808 assert!(
1809 store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1810 "parsed_cache grew to {} entries (cap {})",
1811 store.caches.parsed_cache.len(),
1812 PARSED_CACHE_CAP
1813 );
1814 }
1815
1816 #[test]
1820 fn parsed_cache_shed_keeps_recently_used_entries() {
1821 let store = DocumentStore::new();
1822 use crate::document::cache_registry::PARSED_CACHE_CAP;
1823
1824 let hot = uri("/cap/hot.php");
1825 store.ingest(hot.clone(), "<?php\nclass Hot {}");
1826 let _ = store.get_doc_salsa(&hot);
1827
1828 for i in 0..(PARSED_CACHE_CAP - 2) {
1831 let u = uri(&format!("/cap/cold{i}.php"));
1832 store.ingest(u.clone(), "<?php\nclass A {}");
1833 let _ = store.get_doc_salsa(&u);
1834 }
1835 let _ = store.get_doc_salsa(&hot);
1836
1837 let trigger = uri("/cap/trigger.php");
1839 store.ingest(trigger.clone(), "<?php\nclass T {}");
1840 let _ = store.get_doc_salsa(&trigger);
1841
1842 assert!(
1843 store.caches.parsed_cache.contains_key(&hot),
1844 "recently-touched entry must survive the recency shed"
1845 );
1846 assert!(
1847 store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1848 "cache must stay bounded after the shed"
1849 );
1850 }
1851
1852 #[test]
1856 fn analysis_cache_stays_bounded_under_many_files() {
1857 use crate::document::cache_registry::ANALYSIS_CACHE_CAP;
1858 let store = DocumentStore::new();
1859 let overflow = ANALYSIS_CACHE_CAP + 16;
1860 for i in 0..overflow {
1861 let u = uri(&format!("/acap/file{i}.php"));
1862 store.ingest(u.clone(), "<?php\nfunction f() { return 1; }");
1863 let _ = store.cached_analysis(&u);
1864 }
1865 assert!(
1866 store.caches.analysis_cache.len() <= ANALYSIS_CACHE_CAP,
1867 "analysis_cache grew to {} entries (cap {})",
1868 store.caches.analysis_cache.len(),
1869 ANALYSIS_CACHE_CAP
1870 );
1871 }
1872
1873 #[test]
1874 fn get_doc_salsa_cache_hits_across_calls() {
1875 let store = DocumentStore::new();
1876 let u = uri("/g3_cache.php");
1877 open(&store, u.clone(), "<?php\nclass G3 {}".to_string());
1878
1879 let a = store.get_doc_salsa(&u).unwrap();
1880 let b = store.get_doc_salsa(&u).unwrap();
1881 assert!(
1882 Arc::ptr_eq(&a, &b),
1883 "parsed_cache hit should yield the same Arc across calls"
1884 );
1885
1886 open(&store, u.clone(), "<?php\nclass G3b {}".to_string());
1887 let c = store.get_doc_salsa(&u).unwrap();
1888 assert!(
1889 !Arc::ptr_eq(&a, &c),
1890 "edit should invalidate the parsed_cache entry"
1891 );
1892 }
1893
1894 #[test]
1895 fn get_doc_salsa_returns_some_for_mirrored_files() {
1896 let store = DocumentStore::new();
1899 let u = uri("/e4_doc.php");
1900 store.ingest(u.clone(), "<?php\nclass P {}");
1901 assert!(store.get_doc_salsa(&u).is_some());
1902 }
1903
1904 #[test]
1905 fn get_doc_snapshot_or_stale_matches_salsa_on_settled_buffer() {
1906 let store = DocumentStore::new();
1907 let u = uri("/stale_settled.php");
1908 open(&store, u.clone(), "<?php\nclass S {}".to_string());
1909 let fresh = store.get_doc_salsa(&u).unwrap();
1910 let stale = store.get_doc_snapshot_or_stale(&u).unwrap();
1911 assert!(
1912 Arc::ptr_eq(&fresh, &stale),
1913 "on a settled buffer the stale-tolerant accessor must serve the cached parse"
1914 );
1915 }
1916
1917 #[test]
1921 fn get_doc_snapshot_or_stale_stays_responsive_under_writes() {
1922 use std::sync::Arc;
1923 use std::thread;
1924 use std::time::{Duration, Instant};
1925
1926 let store = Arc::new(DocumentStore::new());
1927 let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/hl{i}.php"))).collect();
1928 for (i, u) in urls.iter().enumerate() {
1929 open(&store, u.clone(), format!("<?php\nclass H{i} {{}}"));
1930 assert!(store.get_doc_salsa(u).is_some());
1932 }
1933
1934 let deadline = Instant::now() + Duration::from_millis(400);
1935 let mut handles = Vec::new();
1936
1937 {
1938 let store = Arc::clone(&store);
1939 let urls = urls.clone();
1940 handles.push(thread::spawn(move || {
1941 let mut rev = 0u32;
1942 while Instant::now() < deadline {
1943 for u in &urls {
1944 store.mirror_text(u, &format!("<?php\nclass H {{}}\n// rev {rev}"));
1945 }
1946 rev += 1;
1947 }
1948 }));
1949 }
1950
1951 for _ in 0..4 {
1952 let store = Arc::clone(&store);
1953 let urls = urls.clone();
1954 handles.push(thread::spawn(move || {
1955 while Instant::now() < deadline {
1956 for u in &urls {
1957 assert!(
1958 store.get_doc_snapshot_or_stale(u).is_some(),
1959 "open file must resolve to a fresh or stale parse, never None"
1960 );
1961 }
1962 }
1963 }));
1964 }
1965
1966 for h in handles {
1967 h.join()
1968 .expect("stale accessor must not panic or spin under concurrent writes");
1969 }
1970 }
1971
1972 #[test]
1977 #[ignore = "timing measurement; run with --ignored --nocapture"]
1978 fn ws1_ab_latency_under_write_pressure() {
1979 use std::sync::Arc;
1980 use std::sync::atomic::{AtomicBool, Ordering};
1981 use std::thread;
1982 use std::time::{Duration, Instant};
1983
1984 fn big_source(rev: u32) -> String {
1988 let mut s = String::from("<?php\nclass Big {\n");
1989 for i in 0..3000 {
1990 s.push_str(&format!(
1991 " public function m{i}(int $a, string $b): int {{ return $a + {i}; }}\n"
1992 ));
1993 }
1994 s.push_str(&format!("}}\n// rev {rev}\n"));
1995 s
1996 }
1997
1998 fn measure(
1999 store: &Arc<DocumentStore>,
2000 url: &Url,
2001 writers: usize,
2002 label: &str,
2003 call: impl Fn(&DocumentStore, &Url) -> bool,
2004 ) {
2005 let stop = Arc::new(AtomicBool::new(false));
2006 let mut writer_handles = Vec::new();
2007 for w in 0..writers {
2008 let store = Arc::clone(store);
2009 let url = url.clone();
2010 let stop = Arc::clone(&stop);
2011 writer_handles.push(thread::spawn(move || {
2012 let mut rev = (w as u32) * 1_000_000;
2013 while !stop.load(Ordering::Relaxed) {
2014 store.mirror_text(&url, &big_source(rev));
2015 rev = rev.wrapping_add(1);
2016 }
2017 }));
2018 }
2019 thread::sleep(Duration::from_millis(30)); let n = 100usize;
2022 let mut max = Duration::ZERO;
2023 let mut total = Duration::ZERO;
2024 for _ in 0..n {
2025 let t = Instant::now();
2026 let ok = call(store, url);
2027 let dt = t.elapsed();
2028 assert!(ok, "{label}: accessor returned None");
2029 max = max.max(dt);
2030 total += dt;
2031 }
2032 stop.store(true, Ordering::Relaxed);
2033 for h in writer_handles {
2034 h.join().unwrap();
2035 }
2036 println!(
2037 "{label}: {n} calls, {writers} writers — max {:>10.3?}, mean {:>10.3?}",
2038 max,
2039 total / n as u32
2040 );
2041 }
2042
2043 let store = Arc::new(DocumentStore::new());
2044 let url = uri("/perf_big.php");
2045 open(&store, url.clone(), big_source(0));
2046 assert!(store.get_doc_salsa(&url).is_some()); for writers in [2usize, 4] {
2049 measure(
2050 &store,
2051 &url,
2052 writers,
2053 "NEW get_doc_snapshot_or_stale",
2054 |s, u| s.get_doc_snapshot_or_stale(u).is_some(),
2055 );
2056 measure(
2057 &store,
2058 &url,
2059 writers,
2060 "OLD get_doc_salsa (unbounded) ",
2061 |s, u| s.get_doc_salsa(u).is_some(),
2062 );
2063 }
2064 }
2065
2066 #[test]
2067 fn get_salsa_accessors_return_none_for_unknown_uri() {
2068 let store = DocumentStore::new();
2069 let u = uri("/never-seen.php");
2070 assert!(store.get_doc_salsa(&u).is_none());
2071 assert!(store.get_index_salsa(&u).is_none());
2072 }
2073
2074 #[test]
2082 fn concurrent_reads_and_writes_do_not_panic() {
2083 use std::sync::Arc;
2084 use std::thread;
2085 use std::time::{Duration, Instant};
2086
2087 let store = Arc::new(DocumentStore::new());
2088 let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/f{i}.php"))).collect();
2089 for (i, u) in urls.iter().enumerate() {
2090 open(&store, u.clone(), format!("<?php\nclass C{i} {{}}"));
2091 }
2092
2093 let deadline = Instant::now() + Duration::from_millis(400);
2094 let mut handles = Vec::new();
2095
2096 {
2098 let store = Arc::clone(&store);
2099 let urls = urls.clone();
2100 handles.push(thread::spawn(move || {
2101 let mut rev = 0u32;
2102 while Instant::now() < deadline {
2103 for u in &urls {
2104 let text = format!("<?php\nclass C{{}}\n// rev {rev}");
2105 store.mirror_text(u, &text);
2106 }
2107 rev += 1;
2108 }
2109 }));
2110 }
2111
2112 for _ in 0..4 {
2114 let store = Arc::clone(&store);
2115 let urls = urls.clone();
2116 handles.push(thread::spawn(move || {
2117 while Instant::now() < deadline {
2118 for u in &urls {
2119 let _ = store.get_doc_salsa(u);
2120 let _ = store.get_index_salsa(u);
2121 }
2122 }
2126 }));
2127 }
2128
2129 for h in handles {
2130 h.join().expect("no panic under concurrent read/write");
2131 }
2132 }
2133
2134 #[test]
2138 fn psr4_lazy_load_suppresses_undefined_class() {
2139 let tmp = tempfile::tempdir().unwrap();
2140
2141 std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
2143 std::fs::write(
2144 tmp.path().join("src/Model/Entity.php"),
2145 "<?php\nnamespace App\\Model;\nclass Entity {}\n",
2146 )
2147 .unwrap();
2148
2149 std::fs::write(
2151 tmp.path().join("composer.json"),
2152 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
2153 )
2154 .unwrap();
2155
2156 let store = DocumentStore::new();
2157
2158 store
2160 .psr4
2161 .store(Arc::new(crate::lang::autoload::Psr4Map::load(tmp.path())));
2162
2163 let handler_url = Url::from_file_path(tmp.path().join("src/Service/Handler.php")).unwrap();
2167 store.mirror_text(
2168 &handler_url,
2169 "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\nfunction handle(Entity $e): Entity { return $e; }\n",
2170 );
2171
2172 let issues = store.get_semantic_issues_salsa(&handler_url).unwrap();
2173 let undef: Vec<_> = issues
2174 .iter()
2175 .filter(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. }))
2176 .collect();
2177 assert!(
2178 undef.is_empty(),
2179 "PSR-4 lazy-loading must prevent UndefinedClass for App\\Model\\Entity; got: {undef:?}"
2180 );
2181 }
2182
2183 #[test]
2198 fn all_docs_for_scan_does_not_reparse_indexed_files() {
2199 let store = DocumentStore::new();
2200 const N: usize = 50;
2201 for i in 0..N {
2202 let u = uri(&format!("/scan/file{i}.php"));
2203 store.ingest(u, &format!("<?php\nclass C{i} {{}}\nfunction f{i}() {{}}"));
2204 }
2205
2206 let first: Vec<_> = store.all_docs_for_scan();
2207 let second: Vec<_> = store.all_docs_for_scan();
2208 assert_eq!(first.len(), N);
2209 assert_eq!(second.len(), N);
2210
2211 let by_url_first: std::collections::HashMap<Url, Arc<ParsedDoc>> =
2212 first.into_iter().collect();
2213 for (u, doc2) in second {
2214 let doc1 = by_url_first
2215 .get(&u)
2216 .expect("second scan returned a URL the first didn't");
2217 assert!(
2218 Arc::ptr_eq(doc1, &doc2),
2219 "{u} re-parsed across all_docs_for_scan calls — \
2220 cache (parsed_cache + salsa parsed_doc memo) failed to hit"
2221 );
2222 }
2223
2224 let edited_url = uri("/scan/file0.php");
2227 let pre_edit = store.get_doc_salsa(&edited_url).unwrap();
2228 store.ingest(edited_url.clone(), "<?php\nclass C0Edited {}");
2229 let post_edit = store.get_doc_salsa(&edited_url).unwrap();
2230 assert!(
2231 !Arc::ptr_eq(&pre_edit, &post_edit),
2232 "edited file must produce a fresh ParsedDoc"
2233 );
2234 for i in 1..N {
2235 let u = uri(&format!("/scan/file{i}.php"));
2236 let original = by_url_first.get(&u).unwrap();
2237 let after = store.get_doc_salsa(&u).unwrap();
2238 assert!(
2239 Arc::ptr_eq(original, &after),
2240 "{u} should not have re-parsed because of an unrelated edit"
2241 );
2242 }
2243 }
2244
2245 #[test]
2250 fn body_only_edit_does_not_invalidate_sibling_analysis_cache() {
2251 let store = DocumentStore::new();
2252 let ua = uri("/ic_a.php");
2253 let ub = uri("/ic_b.php");
2254
2255 open(
2257 &store,
2258 ua.clone(),
2259 "<?php\nfunction a() { return 1; }".to_string(),
2260 );
2261 open(
2262 &store,
2263 ub.clone(),
2264 "<?php\nfunction b() { return 2; }".to_string(),
2265 );
2266 let _ = store.cached_analysis(&ua).unwrap();
2267 let analysis_b_first = store.cached_analysis(&ub).unwrap();
2268 let ver_after_warm = store.caches.decl_version();
2269
2270 store.mirror_text(&ua, "<?php\nfunction a() { return 999; }");
2272 let _ = store.cached_analysis(&ua);
2273 let ver_after_body_edit = store.caches.decl_version();
2274 assert_eq!(
2275 ver_after_warm, ver_after_body_edit,
2276 "body-only edit must not bump decl_version"
2277 );
2278
2279 let analysis_b_second = store.cached_analysis_if_fresh(&ub);
2281 assert!(
2282 analysis_b_second.is_some(),
2283 "B's analysis should hit cache after body-only edit to A"
2284 );
2285 assert!(
2286 Arc::ptr_eq(&analysis_b_first, &analysis_b_second.unwrap()),
2287 "B's analysis should be the identical Arc (no re-analysis)"
2288 );
2289
2290 store.mirror_text(&ua, "<?php\nfunction a_renamed() { return 999; }");
2292 let _ = store.cached_analysis(&ua);
2293 let ver_after_decl_edit = store.caches.decl_version();
2294 assert!(
2295 ver_after_decl_edit > ver_after_body_edit,
2296 "declaration edit must bump decl_version (was {ver_after_body_edit}, now {ver_after_decl_edit})"
2297 );
2298
2299 let analysis_b_stale = store.cached_analysis_if_fresh(&ub);
2301 assert!(
2302 analysis_b_stale.is_none(),
2303 "B's analysis should be stale after A's declaration changed"
2304 );
2305 }
2306
2307 #[test]
2311 fn snapshot_query_survives_concurrent_writes() {
2312 use std::sync::Arc;
2313 use std::thread;
2314 use std::time::{Duration, Instant};
2315
2316 let store = Arc::new(DocumentStore::new());
2317 let u = uri("/sq_test.php");
2318 open(
2319 &store,
2320 u.clone(),
2321 "<?php\nfunction f(): int { return 1; }".to_string(),
2322 );
2323
2324 let deadline = Instant::now() + Duration::from_millis(200);
2325 let mut handles = Vec::new();
2326
2327 {
2329 let store = Arc::clone(&store);
2330 let u = u.clone();
2331 handles.push(thread::spawn(move || {
2332 let mut rev = 0u32;
2333 while Instant::now() < deadline {
2334 store.mirror_text(&u, &format!("<?php\nfunction f(): int {{ return {rev}; }}"));
2335 rev += 1;
2336 }
2337 }));
2338 }
2339
2340 for _ in 0..4 {
2342 let store = Arc::clone(&store);
2343 let u = u.clone();
2344 handles.push(thread::spawn(move || {
2345 while Instant::now() < deadline {
2346 let _ = store.get_doc_salsa(&u);
2347 let _ = store.get_index_salsa(&u);
2348 }
2349 }));
2350 }
2351
2352 for h in handles {
2353 h.join()
2354 .expect("no panic in snapshot_query under concurrent writes");
2355 }
2356 }
2357
2358 #[test]
2362 fn owned_program_cache_reused_after_sibling_declaration_change() {
2363 let store = DocumentStore::new();
2364 let ua = uri("/prog_a.php");
2365 let ub = uri("/prog_b.php");
2366
2367 open(
2368 &store,
2369 ua.clone(),
2370 "<?php\nfunction alpha(): void {}".to_string(),
2371 );
2372 open(
2373 &store,
2374 ub.clone(),
2375 "<?php\nfunction beta(): void {}".to_string(),
2376 );
2377
2378 let _ = store.cached_analysis(&ua);
2380 let _ = store.cached_analysis(&ub);
2381
2382 let prog_b_first = store
2384 .caches
2385 .owned_program_cache
2386 .get(&ub)
2387 .map(|e| Arc::clone(&e.1))
2388 .expect("B's owned_program should be cached after first analysis");
2389
2390 store.mirror_text(&ua, "<?php\nfunction alpha_renamed(): void {}");
2392 let _ = store.cached_analysis(&ua);
2394
2395 let _ = store.cached_analysis(&ub);
2397
2398 let prog_b_second = store
2399 .caches
2400 .owned_program_cache
2401 .get(&ub)
2402 .map(|e| Arc::clone(&e.1))
2403 .expect("B's owned_program should still be cached after sibling edit");
2404
2405 assert!(
2406 Arc::ptr_eq(&prog_b_first, &prog_b_second),
2407 "B's owned_program Arc should be identical (cache hit) after sibling declaration change"
2408 );
2409 }
2410
2411 #[test]
2413 fn write_rev_increments_on_write() {
2414 let store = DocumentStore::new();
2415 let u = uri("/rev_test.php");
2416 let before = store.write_rev();
2417 store.mirror_text(&u, "<?php echo 1;");
2418 let after_first = store.write_rev();
2419 assert!(after_first > before, "first write must bump the revision");
2420 store.mirror_text(&u, "<?php echo 1;");
2422 assert_eq!(
2423 store.write_rev(),
2424 after_first,
2425 "identical text must not bump the revision"
2426 );
2427 store.mirror_text(&u, "<?php echo 2;");
2429 assert!(
2430 store.write_rev() > after_first,
2431 "changed text must bump the revision"
2432 );
2433 }
2434
2435 #[test]
2438 fn begin_reanalyze_cancels_previous_token() {
2439 let store = DocumentStore::new();
2440 let first = store.begin_reanalyze();
2441 assert!(!first.is_cancelled(), "a fresh token starts un-cancelled");
2442 let second = store.begin_reanalyze();
2443 assert!(
2444 first.is_cancelled(),
2445 "issuing a new token must cancel the previous in-flight sweep"
2446 );
2447 assert!(
2448 !second.is_cancelled(),
2449 "the newly-issued token must itself be un-cancelled"
2450 );
2451 }
2452
2453 #[test]
2455 fn write_rev_increments_on_remove() {
2456 let store = DocumentStore::new();
2457 let u = uri("/rev_remove.php");
2458 store.mirror_text(&u, "<?php class A {}");
2459 let before = store.write_rev();
2460 store.remove(&u);
2461 assert!(store.write_rev() > before, "remove must bump the revision");
2462 }
2463}