1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, Mutex, OnceLock};
3
4use arc_swap::ArcSwap;
5
6use dashmap::{DashMap, DashSet};
7use salsa::Setter;
8use tower_lsp::lsp_types::{SemanticToken, Url};
9
10use crate::db::mir_queries::{LspWorkspace, LspWsFile};
11use crate::document::ast::ParsedDoc;
12use crate::document::cache_registry::CacheRegistry;
13use crate::index::file_index::FileIndex;
14use crate::lang::autoload::Psr4Map;
15
16pub struct DocumentStore {
17 caches: CacheRegistry,
19
20 lsp_ws_files: DashMap<Url, LspWsFile>,
30 deleted_uris: DashSet<Url>,
33 workspace_files_dirty: AtomicBool,
37 lsp_workspace: Mutex<Option<LspWorkspace>>,
41 php_version: Mutex<mir_analyzer::PhpVersion>,
44 psr4: Arc<ArcSwap<Psr4Map>>,
49 analysis_session: Mutex<Option<(mir_analyzer::PhpVersion, Arc<mir_analyzer::AnalysisSession>)>>,
53 session_cache_dir: OnceLock<std::path::PathBuf>,
57 autoload_uris: std::sync::RwLock<Vec<Url>>,
62 vendor_index_cache: DashMap<Url, Arc<FileIndex>>,
68}
69
70impl Default for DocumentStore {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76impl DocumentStore {
77 pub fn new() -> Self {
78 DocumentStore {
79 caches: CacheRegistry::new(),
80 lsp_ws_files: DashMap::new(),
81 deleted_uris: DashSet::new(),
82 workspace_files_dirty: AtomicBool::new(true),
83 lsp_workspace: Mutex::new(None),
84 php_version: Mutex::new(mir_analyzer::PhpVersion::LATEST),
85 psr4: Arc::new(ArcSwap::from_pointee(Psr4Map::empty())),
86 analysis_session: Mutex::new(None),
87 session_cache_dir: OnceLock::new(),
88 autoload_uris: std::sync::RwLock::new(Vec::new()),
89 vendor_index_cache: DashMap::new(),
90 }
91 }
92
93 pub fn set_session_cache_dir(&self, dir: std::path::PathBuf) {
97 let _ = self.session_cache_dir.set(dir);
98 }
99
100 pub fn set_autoload_uris(&self, uris: Vec<Url>) {
106 *self.autoload_uris.write().unwrap() = uris;
107 self.caches.evict_analysis_all();
108 }
109
110 fn get_or_build_session(
116 &self,
117 php_version: mir_analyzer::PhpVersion,
118 ) -> Arc<mir_analyzer::AnalysisSession> {
119 let mut guard = self.analysis_session.lock().unwrap();
120 if let Some((cached_ver, session)) = guard.as_ref()
121 && *cached_ver == php_version
122 {
123 return Arc::clone(session);
124 }
125 let resolver: Arc<dyn mir_analyzer::ClassResolver> = self.psr4.load_full();
129 let mut builder =
130 mir_analyzer::AnalysisSession::new(php_version).with_class_resolver(resolver);
131 if let Some(dir) = self.session_cache_dir.get() {
132 builder = builder.with_cache_dir(dir);
133 }
134 let session = Arc::new(builder);
135 *guard = Some((php_version, Arc::clone(&session)));
136 session
137 }
138
139 pub fn analysis_session(
145 &self,
146 php_version: mir_analyzer::PhpVersion,
147 ) -> Arc<mir_analyzer::AnalysisSession> {
148 let session = self.get_or_build_session(php_version);
149 session.ensure_all_stubs();
150 session
151 }
152
153 pub fn workspace_php_version(&self) -> mir_analyzer::PhpVersion {
155 *self.php_version.lock().unwrap()
156 }
157
158 pub fn psr4_arc(&self) -> Arc<ArcSwap<Psr4Map>> {
163 Arc::clone(&self.psr4)
164 }
165
166 fn input_durability(uri: &Url) -> salsa::Durability {
169 if uri.as_str().contains("/vendor/") {
170 salsa::Durability::HIGH
171 } else {
172 salsa::Durability::LOW
173 }
174 }
175
176 fn lsp_ws_file(&self, uri: &Url) -> Option<LspWsFile> {
178 if self.deleted_uris.contains(uri) {
179 return None;
180 }
181 self.lsp_ws_files.get(uri).map(|e| *e)
182 }
183
184 fn snapshot_mir_query<R>(&self, f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R) -> R {
188 use std::panic::AssertUnwindSafe;
189 let session = self.get_or_build_session(self.workspace_php_version());
190 loop {
195 let db = session.snapshot_db();
196 match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
197 Ok(r) => return r,
198 Err(_) => drop(db),
199 }
200 }
201 }
202
203 pub fn mirror_text(&self, uri: &Url, text: &str) {
208 if let Some(cached) = self.caches.text_cache.get(uri)
214 && **cached == *text
215 && !self.deleted_uris.contains(uri)
216 && self.lsp_ws_files.contains_key(uri)
217 {
218 return;
219 }
220 self.mirror_text_arc(uri, Arc::from(text))
221 }
222
223 pub fn mirror_text_arc(&self, uri: &Url, text_arc: Arc<str>) {
230 let dur = Self::input_durability(uri);
231 let path: Arc<str> = Arc::from(uri.as_str());
232 let session = self.get_or_build_session(self.workspace_php_version());
233 if let Some(wf) = self.lsp_ws_files.get(uri).map(|e| *e) {
234 self.deleted_uris.remove(uri);
235 if let Some(cached) = self.caches.text_cache.get(uri)
238 && **cached == *text_arc
239 {
240 return;
241 }
242 session.with_db_mut(|db| {
243 let sf = wf.source(db);
244 sf.set_text(db).with_durability(dur).to(text_arc.clone());
245 if wf.cached_index(db).is_some() {
248 wf.set_cached_index(db).to(None);
249 }
250 });
251 self.caches.text_cache.insert(uri.clone(), text_arc);
252 self.caches.evict_analysis(uri);
255 } else {
256 let wf = session.with_db_mut(|db| {
257 let sf = db.upsert_source_file_with_durability(path, text_arc.clone(), dur);
258 LspWsFile::new(db, sf, None)
259 });
260 self.lsp_ws_files.insert(uri.clone(), wf);
261 self.caches.text_cache.insert(uri.clone(), text_arc);
262 self.workspace_files_dirty.store(true, Ordering::Release);
263 }
264 }
265
266 #[cfg(test)]
268 pub fn source_file(&self, uri: &Url) -> Option<LspWsFile> {
269 if self.deleted_uris.contains(uri) {
270 return None;
271 }
272 self.lsp_ws_files.get(uri).map(|e| *e)
273 }
274
275 pub fn seed_cached_index(&self, uri: &Url, index: Arc<FileIndex>) -> bool {
288 let Some(wf) = self.lsp_ws_file(uri) else {
289 return false;
290 };
291 let session = self.get_or_build_session(self.workspace_php_version());
292 session.with_db_mut(|db| wf.set_cached_index(db).to(Some(index)));
293 true
294 }
295
296 pub fn evict_token_cache(&self, uri: &Url) {
300 self.caches.evict_tokens(uri);
301 }
302
303 #[cfg(test)]
309 pub fn source_files_len(&self) -> usize {
310 self.lsp_ws_files.len()
311 }
312
313 #[cfg(test)]
314 pub fn snapshot_query_file_index(
315 &self,
316 uri: &Url,
317 ) -> Option<crate::index::file_index::FileIndex> {
318 let wf = self.lsp_ws_file(uri)?;
319 Some(
320 self.snapshot_mir_query(move |db| {
321 (*crate::db::mir_queries::file_index(db, wf).0).clone()
322 }),
323 )
324 }
325
326 pub fn ingest(&self, uri: Url, text: &str) {
331 self.mirror_text(&uri, text);
332 }
333
334 pub fn ingest_from_doc(&self, uri: Url, doc: &ParsedDoc) {
343 self.mirror_text_arc(&uri, doc.source_arc());
344 }
345
346 pub fn remove(&self, uri: &Url) {
347 self.caches.evict(uri);
348 self.deleted_uris.insert(uri.clone());
353 self.workspace_files_dirty.store(true, Ordering::Release);
354 self.sync_workspace_files();
357 let guard = self.analysis_session.lock().unwrap();
361 if let Some((_, session)) = guard.as_ref() {
362 session.invalidate_file(uri.as_str());
363 }
364 }
365
366 pub fn get_doc_salsa(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
379 self.get_parsed_cached(uri)
380 }
381
382 pub fn get_index_salsa(&self, uri: &Url) -> Option<Arc<FileIndex>> {
384 let wf = self.lsp_ws_file(uri)?;
385 Some(
386 self.snapshot_mir_query(move |db| crate::db::mir_queries::file_index(db, wf).0.clone()),
387 )
388 }
389
390 pub fn get_symbol_map_salsa(
393 &self,
394 uri: &Url,
395 ) -> Option<Arc<crate::types::symbol_map::SymbolMap>> {
396 let wf = self.lsp_ws_file(uri)?;
398 Some(self.snapshot_mir_query(move |db| {
399 let sf = wf.source(db);
400 crate::db::mir_queries::symbol_map(db, sf).0.clone()
401 }))
402 }
403
404 pub fn other_symbol_maps(
406 &self,
407 uri: &Url,
408 open_urls: &[Url],
409 ) -> Vec<(Url, Arc<crate::types::symbol_map::SymbolMap>)> {
410 open_urls
411 .iter()
412 .filter(|u| *u != uri)
413 .filter_map(|u| self.get_symbol_map_salsa(u).map(|m| (u.clone(), m)))
414 .collect()
415 }
416
417 fn get_parsed_cached(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
424 if let Some(current_text) = self.caches.text_cache.get(uri)
425 && let Some(entry) = self.caches.parsed_cache.get(uri)
426 && Arc::ptr_eq(&*current_text, &entry.0)
427 {
428 return Some(entry.1.clone());
429 }
430
431 let wf = self.lsp_ws_file(uri)?;
433 let (text, doc) = self.snapshot_mir_query(move |db| {
434 let sf = wf.source(db);
435 let text = sf.text(db);
436 let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
437 (text, doc)
438 });
439 self.caches.insert_parsed(uri.clone(), text, doc.clone());
440 Some(doc)
441 }
442
443 pub fn sync_workspace_files(&self) {
448 if !self.workspace_files_dirty.swap(false, Ordering::AcqRel) {
451 return;
452 }
453
454 let mut entries: Vec<(Arc<str>, LspWsFile)> = self
456 .lsp_ws_files
457 .iter()
458 .filter(|e| !self.deleted_uris.contains(e.key()))
459 .map(|e| (Arc::<str>::from(e.key().as_str()), *e.value()))
460 .collect();
461 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
462 let files: Arc<[LspWsFile]> = entries.iter().map(|(_, wf)| *wf).collect();
463
464 let session = self.get_or_build_session(self.workspace_php_version());
465 let mut guard = self.lsp_workspace.lock().unwrap();
466 session.with_db_mut(|db| match *guard {
467 Some(ws) => {
468 ws.set_files(db).to(files);
469 }
470 None => *guard = Some(LspWorkspace::new(db, files)),
471 });
472 }
473
474 pub fn mark_workspace_files_dirty(&self) {
478 self.workspace_files_dirty.store(true, Ordering::Release);
479 }
480
481 pub fn set_php_version(&self, version: mir_analyzer::PhpVersion) {
486 {
487 let mut guard = self.php_version.lock().unwrap();
488 if *guard == version {
489 return;
490 }
491 *guard = version;
492 }
493 self.lsp_ws_files.clear();
498 *self.lsp_workspace.lock().unwrap() = None;
499 self.workspace_files_dirty.store(true, Ordering::Release);
500 self.caches.evict_analysis_all();
502 }
503
504 pub fn session_references_to(
512 &self,
513 symbol: &mir_analyzer::Name,
514 files: &[Arc<str>],
515 ) -> Vec<(Arc<str>, u32, u32, u32)> {
516 let php_version = self.workspace_php_version();
517 let raw = loop {
521 let session = self.analysis_session(php_version);
522 if let Ok(refs) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
523 session.references_to_in_files(symbol, files)
524 })) {
525 break refs;
526 }
527 };
528 raw.into_iter()
529 .map(|(file, range)| {
530 let line = range.start.line.saturating_sub(1);
532 let col_start = range.start.column;
533 let col_end = range.end.column;
534 (file, line, col_start, col_end)
535 })
536 .collect()
537 }
538
539 pub fn method_reference_scope(&self, owner_fqn: &str, method: &str) -> Option<Vec<Url>> {
556 use crate::index::file_index::{ClassKind, Visibility};
557
558 let ws = self.get_workspace_index_salsa();
559 let owner_fqn = owner_fqn.trim_start_matches('\\');
560 let owner_short = crate::text::fqn_short_name(owner_fqn);
561
562 let (decl_uri, decl_class) = ws
563 .classes_by_name
564 .get(owner_short)?
565 .iter()
566 .filter_map(|&r| ws.at(r))
567 .find(|(_, cls)| cls.fqn.trim_start_matches('\\') == owner_fqn)?;
568
569 if !matches!(decl_class.kind, ClassKind::Class | ClassKind::Enum)
572 || !decl_class.traits.is_empty()
573 || !decl_class.mixins.is_empty()
574 {
575 return None;
576 }
577
578 let vis = decl_class
579 .methods
580 .iter()
581 .find(|m| m.name.eq_ignore_ascii_case(method))?
582 .visibility;
583
584 match vis {
585 Visibility::Private => Some(vec![decl_uri.clone()]),
586 Visibility::Protected | Visibility::Public => None,
587 }
588 }
589
590 pub fn get_workspace_index_salsa(&self) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
600 self.sync_workspace_files();
601 let ws = *self.lsp_workspace.lock().unwrap();
602 let Some(ws) = ws else {
603 return Arc::new(crate::db::workspace_index::WorkspaceIndexData {
604 files: Vec::new(),
605 classes_by_name: std::collections::HashMap::new(),
606 subtypes_of: std::collections::HashMap::new(),
607 decls_by_name: std::collections::HashMap::new(),
608 });
609 };
610 self.snapshot_mir_query(move |db| crate::db::mir_queries::workspace_index(db, ws).0.clone())
611 }
612
613 pub fn source_text(&self, uri: &Url) -> Option<Arc<str>> {
617 self.caches.text_cache.get(uri).map(|e| Arc::clone(&e))
618 }
619
620 pub fn store_token_cache(&self, uri: &Url, result_id: String, tokens: Arc<Vec<SemanticToken>>) {
623 self.caches.store_token(uri, result_id, tokens);
624 }
625
626 pub fn get_token_cache(&self, uri: &Url, result_id: &str) -> Option<Arc<Vec<SemanticToken>>> {
628 self.caches.get_token(uri, result_id)
629 }
630
631 #[tracing::instrument(skip_all)]
637 pub fn get_semantic_issues_salsa(&self, uri: &Url) -> Option<Arc<[mir_issues::Issue]>> {
638 let analysis = self.cached_analysis(uri)?;
639 let file: Arc<str> = Arc::from(uri.as_str());
640 let class_issues = {
646 let _s = tracing::debug_span!("session.class_issues_for").entered();
647 loop {
649 let session = self.analysis_session(self.workspace_php_version());
650 if let Ok(issues) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
651 session.class_issues(std::slice::from_ref(&file))
652 })) {
653 break issues;
654 }
655 }
656 };
657 let combined: Vec<mir_issues::Issue> = analysis
658 .issues
659 .iter()
660 .cloned()
661 .chain(class_issues)
662 .filter(|i| !i.suppressed)
663 .collect();
664 Some(Arc::from(combined))
665 }
666
667 pub fn cached_type_map(
682 &self,
683 uri: &Url,
684 doc: &crate::document::ast::ParsedDoc,
685 meta: Option<&crate::lang::phpstorm_meta::PhpStormMeta>,
686 ) -> Arc<crate::types::type_map::TypeMap> {
687 let source = doc.source_arc();
688 let meta_key = meta.map_or(0usize, |m| std::ptr::from_ref(m) as usize);
689 if let Some(entry) = self.caches.type_map_cache.get(uri)
690 && Arc::ptr_eq(&entry.0, &source)
691 && entry.1 == meta_key
692 {
693 return Arc::clone(&entry.2);
694 }
695 let map = Arc::new(crate::types::type_map::TypeMap::from_doc_with_meta(
696 doc, meta,
697 ));
698 self.caches
699 .type_map_cache
700 .insert(uri.clone(), (source, meta_key, Arc::clone(&map)));
701 map
702 }
703
704 pub fn cached_analysis_if_fresh(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
710 let doc = self.get_doc_salsa(uri)?;
711 let source = doc.source_arc();
712 let entry = self.caches.analysis_cache.get(uri)?;
713 let cur_ver = self.caches.decl_version();
714 (Arc::ptr_eq(&entry.0, &source) && entry.1 == cur_ver).then(|| Arc::clone(&entry.2))
715 }
716
717 #[tracing::instrument(skip_all)]
718 pub fn cached_analysis(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
719 let doc = self.get_doc_salsa(uri)?;
721 let source = doc.source_arc();
722
723 let cur_ver = self.caches.decl_version();
724 if let Some(entry) = self.caches.analysis_cache.get(uri)
725 && Arc::ptr_eq(&entry.0, &source)
726 && entry.1 == cur_ver
727 {
728 return Some(Arc::clone(&entry.2));
729 }
730
731 let php_version = self.workspace_php_version();
732 let session = self.analysis_session(php_version);
733 let file: Arc<str> = Arc::from(uri.as_str());
734
735 let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
736 let owned_program = if let Some(cached) = self.caches.owned_program_cache.get(uri)
737 && Arc::ptr_eq(&cached.0, &source)
738 {
739 Arc::clone(&cached.1)
740 } else {
741 let prog = Arc::new(php_ast::owned::to_owned_program(doc.program()));
742 self.caches
743 .owned_program_cache
744 .insert(uri.clone(), (Arc::clone(&source), Arc::clone(&prog)));
745 prog
746 };
747
748 let autoload_texts: Vec<(Arc<str>, Arc<str>)> = {
750 let autoload_uris = self.autoload_uris.read().unwrap().clone();
751 autoload_uris
752 .iter()
753 .filter_map(|auri| {
754 self.caches
755 .text_cache
756 .get(auri)
757 .map(|t| (Arc::from(auri.as_str()), Arc::clone(&*t)))
758 })
759 .collect()
760 };
761 let class_fqns = crate::references::collect_referenced_class_fqns(&doc);
763
764 let analysis = loop {
766 let attempt = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
767 session.ingest_file(file.clone(), source.clone());
768 for (afile, atext) in &autoload_texts {
769 session.ingest_file(afile.clone(), atext.clone());
770 }
771 for fqcn in &class_fqns {
772 let _ = session.load_class(fqcn);
773 }
774 let analyzer = mir_analyzer::FileAnalyzer::new(&session);
775 analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map)
776 }));
777 if let Ok(a) = attempt {
778 break Arc::new(a);
779 }
780 };
781 let new_index = self.get_index_salsa(uri);
787 let old_fp = self
788 .caches
789 .decl_fingerprints
790 .get(uri)
791 .map(|e| Arc::clone(&*e));
792 let decl_changed = match (&old_fp, &new_index) {
793 (Some(old), Some(new)) => **old != **new,
794 (None, Some(_)) => true,
795 _ => false,
796 };
797 if decl_changed {
798 if let Some(idx) = new_index {
799 self.caches.decl_fingerprints.insert(uri.clone(), idx);
800 }
801 self.caches.bump_decl_version();
802 }
803 let ver = self.caches.decl_version();
804 self.caches
805 .analysis_cache
806 .insert(uri.clone(), (source, ver, Arc::clone(&analysis)));
807 Some(analysis)
808 }
809
810 pub fn docs_for(&self, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
815 open_urls
816 .iter()
817 .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
818 .collect()
819 }
820
821 pub fn doc_with_others(
824 &self,
825 uri: &Url,
826 doc: Arc<ParsedDoc>,
827 open_urls: &[Url],
828 ) -> Vec<(Url, Arc<ParsedDoc>)> {
829 let mut result = vec![(uri.clone(), doc)];
830 result.extend(self.other_docs(uri, open_urls));
831 result
832 }
833
834 pub fn other_docs(&self, uri: &Url, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
836 open_urls
837 .iter()
838 .filter(|u| *u != uri)
839 .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
840 .collect()
841 }
842
843 pub fn all_indexes(&self) -> Vec<(Url, Arc<FileIndex>)> {
845 self.get_workspace_index_salsa().files.clone()
846 }
847
848 pub fn cache_vendor_index(&self, uri: Url, index: Arc<FileIndex>) {
852 self.vendor_index_cache.insert(uri, index);
853 }
854
855 pub fn get_vendor_index(&self, uri: &Url) -> Option<Arc<FileIndex>> {
857 self.vendor_index_cache.get(uri).map(|e| Arc::clone(&*e))
858 }
859
860 pub fn other_indexes(&self, uri: &Url) -> Vec<(Url, Arc<FileIndex>)> {
862 self.get_workspace_index_salsa()
863 .files
864 .iter()
865 .filter(|(u, _)| u != uri)
866 .cloned()
867 .collect()
868 }
869
870 pub fn all_docs_for_scan(&self) -> Vec<(Url, Arc<ParsedDoc>)> {
874 let urls: Vec<Url> = self
875 .lsp_ws_files
876 .iter()
877 .filter(|e| !self.deleted_uris.contains(e.key()))
878 .map(|e| e.key().clone())
879 .collect();
880 urls.into_iter()
881 .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
882 .collect()
883 }
884
885 pub fn candidate_docs_for(&self, word: &str) -> Vec<(Url, Arc<ParsedDoc>)> {
897 let candidate_urls: Vec<Url> = self
898 .lsp_ws_files
899 .iter()
900 .filter(|e| !self.deleted_uris.contains(e.key()))
901 .filter(|e| {
902 self.caches
903 .text_cache
904 .get(e.key())
905 .map(|src| src.contains(word))
906 .unwrap_or(true)
907 })
908 .map(|e| e.key().clone())
909 .collect();
910 candidate_urls
911 .into_iter()
912 .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
913 .collect()
914 }
915}
916
917#[cfg(test)]
918mod tests {
919 use super::*;
920
921 fn uri(path: &str) -> Url {
922 Url::parse(&format!("file://{path}")).unwrap()
923 }
924
925 fn open(store: &DocumentStore, u: Url, text: String) {
930 store.mirror_text(&u, &text);
931 }
932
933 #[test]
938 fn index_registers_file_in_salsa() {
939 let store = DocumentStore::new();
940 store.ingest(uri("/lib.php"), "<?php\nfunction lib_fn() {}");
941 let idx = store.get_index_salsa(&uri("/lib.php")).unwrap();
942 assert_eq!(idx.functions.len(), 1);
943 assert_eq!(idx.functions[0].name, "lib_fn".into());
944 }
945
946 #[test]
947 fn remove_hides_file_from_index() {
948 let store = DocumentStore::new();
949 let u = uri("/lib.php");
950 store.ingest(u.clone(), "<?php");
951 store.remove(&u);
952 assert!(store.get_index_salsa(&u).is_none());
953 }
954
955 #[test]
956 fn remove_and_reopen_reuses_source_file_handle() {
957 let store = DocumentStore::new();
958 let u = uri("/lib.php");
959 store.ingest(u.clone(), "<?php");
960 let ft_before = store.source_file(&u).unwrap();
961 store.remove(&u);
962 assert!(
963 store.source_file(&u).is_none(),
964 "deleted file should be hidden"
965 );
966 store.mirror_text(&u, "<?php");
967 let ft_after = store.source_file(&u).unwrap();
968 assert!(
969 ft_before == ft_after,
970 "reopen must reuse the same FileText handle"
971 );
972 }
973
974 #[test]
975 fn delete_reopen_churn_does_not_amplify_salsa_inputs() {
976 let store = DocumentStore::new();
977 let uris: Vec<Url> = (0..20).map(|i| uri(&format!("/churn/f{i}.php"))).collect();
978 for u in &uris {
979 store.ingest(u.clone(), "<?php class A {}");
980 }
981 let count_before = store.source_files_len();
982 for _ in 0..10 {
983 for u in &uris {
984 store.remove(u);
985 }
986 for u in &uris {
987 store.ingest(u.clone(), "<?php class A {}");
988 }
989 }
990 assert_eq!(
991 store.source_files_len(),
992 count_before,
993 "delete-reopen cycles must not create new salsa inputs (L1-B regression guard)"
994 );
995 }
996
997 #[test]
998 fn all_indexes_includes_every_mirrored_file() {
999 let store = DocumentStore::new();
1000 open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1001 store.ingest(uri("/b.php"), "<?php\nfunction b() {}");
1002 assert_eq!(store.all_indexes().len(), 2);
1003 }
1004
1005 #[test]
1006 fn other_indexes_excludes_current_uri() {
1007 let store = DocumentStore::new();
1008 open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1009 open(&store, uri("/b.php"), "<?php\nfunction b() {}".to_string());
1010 assert_eq!(store.other_indexes(&uri("/a.php")).len(), 1);
1011 }
1012
1013 #[test]
1014 fn other_docs_excludes_current_uri() {
1015 let store = DocumentStore::new();
1016 let ua = uri("/a.php");
1017 let ub = uri("/b.php");
1018 open(&store, ua.clone(), "<?php\nfunction a() {}".to_string());
1019 open(&store, ub.clone(), "<?php\nfunction b() {}".to_string());
1020 let open_urls = vec![ua.clone(), ub];
1021 assert_eq!(store.other_docs(&ua, &open_urls).len(), 1);
1022 }
1023
1024 #[test]
1025 fn evict_token_cache_removes_entry() {
1026 let store = DocumentStore::new();
1027 let u = uri("/a.php");
1028 open(&store, u.clone(), "<?php".to_string());
1029 store.store_token_cache(&u, "id1".to_string(), Arc::new(vec![]));
1030 assert!(store.get_token_cache(&u, "id1").is_some());
1031 store.evict_token_cache(&u);
1032 assert!(store.get_token_cache(&u, "id1").is_none());
1033 }
1034
1035 #[test]
1036 fn index_populates_file_index_with_symbols() {
1037 let store = DocumentStore::new();
1038 store.ingest(uri("/a.php"), "<?php\nfunction hello() {}");
1039 let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1040 assert_eq!(idx.functions.len(), 1);
1041 assert_eq!(idx.functions[0].name, "hello".into());
1042 }
1043
1044 #[test]
1045 fn open_populates_file_index_with_symbols() {
1046 let store = DocumentStore::new();
1047 open(&store, uri("/a.php"), "<?php\nclass Foo {}".to_string());
1048 let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1049 assert_eq!(idx.classes.len(), 1);
1050 assert_eq!(idx.classes[0].name, "Foo".into());
1051 }
1052
1053 fn names_of(idx: &FileIndex) -> Vec<String> {
1060 let mut out: Vec<String> = idx.classes.iter().map(|c| c.name.to_string()).collect();
1061 out.extend(idx.functions.iter().map(|f| f.name.to_string()));
1062 out.sort();
1063 out
1064 }
1065
1066 fn salsa_index_names(store: &DocumentStore, url: &Url) -> Vec<String> {
1067 store
1068 .snapshot_query_file_index(url)
1069 .map(|idx| names_of(&idx))
1070 .unwrap_or_default()
1071 }
1072
1073 #[test]
1074 fn mirror_tracks_repeated_edits() {
1075 let store = DocumentStore::new();
1076 let u = uri("/mirror.php");
1077
1078 open(&store, u.clone(), "<?php\nclass A {}".to_string());
1079 assert_eq!(salsa_index_names(&store, &u), vec!["A".to_string()]);
1080
1081 open(
1082 &store,
1083 u.clone(),
1084 "<?php\nclass A {}\nclass B {}".to_string(),
1085 );
1086 assert_eq!(
1087 salsa_index_names(&store, &u),
1088 vec!["A".to_string(), "B".to_string()]
1089 );
1090
1091 open(&store, u.clone(), "<?php\nfunction greet() {}".to_string());
1092 assert_eq!(salsa_index_names(&store, &u), vec!["greet".to_string()]);
1093 }
1094
1095 #[test]
1096 fn mirror_tracks_ingest_and_ingest_from_doc() {
1097 let store = DocumentStore::new();
1098
1099 let u1 = uri("/bg1.php");
1101 store.ingest(u1.clone(), "<?php\nclass Bg1 {}");
1102 assert_eq!(salsa_index_names(&store, &u1), vec!["Bg1".to_string()]);
1103
1104 let u2 = uri("/bg2.php");
1106 let doc = crate::analysis::diagnostics::parse_document_no_diags(
1107 "<?php\nclass Bg2 {}\nfunction f() {}",
1108 );
1109 store.ingest_from_doc(u2.clone(), &doc);
1110 assert_eq!(
1111 salsa_index_names(&store, &u2),
1112 vec!["Bg2".to_string(), "f".to_string()]
1113 );
1114 }
1115
1116 #[test]
1127 fn seed_cached_index_noops_for_unknown_uri() {
1128 let store = DocumentStore::new();
1129 let u = uri("/never_mirrored.php");
1130 let index = Arc::new(crate::index::file_index::FileIndex::default());
1131 assert!(!store.seed_cached_index(&u, index));
1132 }
1133
1134 #[test]
1136 fn parsed_cache_stays_bounded_under_many_inserts() {
1137 let store = DocumentStore::new();
1138 use crate::document::cache_registry::PARSED_CACHE_CAP;
1139 let overflow = PARSED_CACHE_CAP + 100;
1140 for i in 0..overflow {
1141 let u = uri(&format!("/cap/file{i}.php"));
1142 store.ingest(u.clone(), "<?php\nclass A {}");
1143 let _ = store.get_doc_salsa(&u);
1145 }
1146 assert!(
1147 store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1148 "parsed_cache grew to {} entries (cap {})",
1149 store.caches.parsed_cache.len(),
1150 PARSED_CACHE_CAP
1151 );
1152 }
1153
1154 #[test]
1155 fn get_doc_salsa_cache_hits_across_calls() {
1156 let store = DocumentStore::new();
1157 let u = uri("/g3_cache.php");
1158 open(&store, u.clone(), "<?php\nclass G3 {}".to_string());
1159
1160 let a = store.get_doc_salsa(&u).unwrap();
1161 let b = store.get_doc_salsa(&u).unwrap();
1162 assert!(
1163 Arc::ptr_eq(&a, &b),
1164 "parsed_cache hit should yield the same Arc across calls"
1165 );
1166
1167 open(&store, u.clone(), "<?php\nclass G3b {}".to_string());
1168 let c = store.get_doc_salsa(&u).unwrap();
1169 assert!(
1170 !Arc::ptr_eq(&a, &c),
1171 "edit should invalidate the parsed_cache entry"
1172 );
1173 }
1174
1175 #[test]
1176 fn get_doc_salsa_returns_some_for_mirrored_files() {
1177 let store = DocumentStore::new();
1180 let u = uri("/e4_doc.php");
1181 store.ingest(u.clone(), "<?php\nclass P {}");
1182 assert!(store.get_doc_salsa(&u).is_some());
1183 }
1184
1185 #[test]
1186 fn get_salsa_accessors_return_none_for_unknown_uri() {
1187 let store = DocumentStore::new();
1188 let u = uri("/never-seen.php");
1189 assert!(store.get_doc_salsa(&u).is_none());
1190 assert!(store.get_index_salsa(&u).is_none());
1191 }
1192
1193 #[test]
1201 fn concurrent_reads_and_writes_do_not_panic() {
1202 use std::sync::Arc;
1203 use std::thread;
1204 use std::time::{Duration, Instant};
1205
1206 let store = Arc::new(DocumentStore::new());
1207 let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/f{i}.php"))).collect();
1208 for (i, u) in urls.iter().enumerate() {
1209 open(&store, u.clone(), format!("<?php\nclass C{i} {{}}"));
1210 }
1211
1212 let deadline = Instant::now() + Duration::from_millis(400);
1213 let mut handles = Vec::new();
1214
1215 {
1217 let store = Arc::clone(&store);
1218 let urls = urls.clone();
1219 handles.push(thread::spawn(move || {
1220 let mut rev = 0u32;
1221 while Instant::now() < deadline {
1222 for u in &urls {
1223 let text = format!("<?php\nclass C{{}}\n// rev {rev}");
1224 store.mirror_text(u, &text);
1225 }
1226 rev += 1;
1227 }
1228 }));
1229 }
1230
1231 for _ in 0..4 {
1233 let store = Arc::clone(&store);
1234 let urls = urls.clone();
1235 handles.push(thread::spawn(move || {
1236 while Instant::now() < deadline {
1237 for u in &urls {
1238 let _ = store.get_doc_salsa(u);
1239 let _ = store.get_index_salsa(u);
1240 }
1241 }
1245 }));
1246 }
1247
1248 for h in handles {
1249 h.join().expect("no panic under concurrent read/write");
1250 }
1251 }
1252
1253 #[test]
1257 fn psr4_lazy_load_suppresses_undefined_class() {
1258 let tmp = tempfile::tempdir().unwrap();
1259
1260 std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
1262 std::fs::write(
1263 tmp.path().join("src/Model/Entity.php"),
1264 "<?php\nnamespace App\\Model;\nclass Entity {}\n",
1265 )
1266 .unwrap();
1267
1268 std::fs::write(
1270 tmp.path().join("composer.json"),
1271 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1272 )
1273 .unwrap();
1274
1275 let store = DocumentStore::new();
1276
1277 store
1279 .psr4
1280 .store(Arc::new(crate::lang::autoload::Psr4Map::load(tmp.path())));
1281
1282 let handler_url = Url::from_file_path(tmp.path().join("src/Service/Handler.php")).unwrap();
1286 store.mirror_text(
1287 &handler_url,
1288 "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\nfunction handle(Entity $e): Entity { return $e; }\n",
1289 );
1290
1291 let issues = store.get_semantic_issues_salsa(&handler_url).unwrap();
1292 let undef: Vec<_> = issues
1293 .iter()
1294 .filter(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. }))
1295 .collect();
1296 assert!(
1297 undef.is_empty(),
1298 "PSR-4 lazy-loading must prevent UndefinedClass for App\\Model\\Entity; got: {undef:?}"
1299 );
1300 }
1301
1302 #[test]
1317 fn all_docs_for_scan_does_not_reparse_indexed_files() {
1318 let store = DocumentStore::new();
1319 const N: usize = 50;
1320 for i in 0..N {
1321 let u = uri(&format!("/scan/file{i}.php"));
1322 store.ingest(u, &format!("<?php\nclass C{i} {{}}\nfunction f{i}() {{}}"));
1323 }
1324
1325 let first: Vec<_> = store.all_docs_for_scan();
1326 let second: Vec<_> = store.all_docs_for_scan();
1327 assert_eq!(first.len(), N);
1328 assert_eq!(second.len(), N);
1329
1330 let by_url_first: std::collections::HashMap<Url, Arc<ParsedDoc>> =
1331 first.into_iter().collect();
1332 for (u, doc2) in second {
1333 let doc1 = by_url_first
1334 .get(&u)
1335 .expect("second scan returned a URL the first didn't");
1336 assert!(
1337 Arc::ptr_eq(doc1, &doc2),
1338 "{u} re-parsed across all_docs_for_scan calls — \
1339 cache (parsed_cache + salsa parsed_doc memo) failed to hit"
1340 );
1341 }
1342
1343 let edited_url = uri("/scan/file0.php");
1346 let pre_edit = store.get_doc_salsa(&edited_url).unwrap();
1347 store.ingest(edited_url.clone(), "<?php\nclass C0Edited {}");
1348 let post_edit = store.get_doc_salsa(&edited_url).unwrap();
1349 assert!(
1350 !Arc::ptr_eq(&pre_edit, &post_edit),
1351 "edited file must produce a fresh ParsedDoc"
1352 );
1353 for i in 1..N {
1354 let u = uri(&format!("/scan/file{i}.php"));
1355 let original = by_url_first.get(&u).unwrap();
1356 let after = store.get_doc_salsa(&u).unwrap();
1357 assert!(
1358 Arc::ptr_eq(original, &after),
1359 "{u} should not have re-parsed because of an unrelated edit"
1360 );
1361 }
1362 }
1363
1364 #[test]
1369 fn body_only_edit_does_not_invalidate_sibling_analysis_cache() {
1370 let store = DocumentStore::new();
1371 let ua = uri("/ic_a.php");
1372 let ub = uri("/ic_b.php");
1373
1374 open(
1376 &store,
1377 ua.clone(),
1378 "<?php\nfunction a() { return 1; }".to_string(),
1379 );
1380 open(
1381 &store,
1382 ub.clone(),
1383 "<?php\nfunction b() { return 2; }".to_string(),
1384 );
1385 let _ = store.cached_analysis(&ua).unwrap();
1386 let analysis_b_first = store.cached_analysis(&ub).unwrap();
1387 let ver_after_warm = store.caches.decl_version();
1388
1389 store.mirror_text(&ua, "<?php\nfunction a() { return 999; }");
1391 let _ = store.cached_analysis(&ua);
1392 let ver_after_body_edit = store.caches.decl_version();
1393 assert_eq!(
1394 ver_after_warm, ver_after_body_edit,
1395 "body-only edit must not bump decl_version"
1396 );
1397
1398 let analysis_b_second = store.cached_analysis_if_fresh(&ub);
1400 assert!(
1401 analysis_b_second.is_some(),
1402 "B's analysis should hit cache after body-only edit to A"
1403 );
1404 assert!(
1405 Arc::ptr_eq(&analysis_b_first, &analysis_b_second.unwrap()),
1406 "B's analysis should be the identical Arc (no re-analysis)"
1407 );
1408
1409 store.mirror_text(&ua, "<?php\nfunction a_renamed() { return 999; }");
1411 let _ = store.cached_analysis(&ua);
1412 let ver_after_decl_edit = store.caches.decl_version();
1413 assert!(
1414 ver_after_decl_edit > ver_after_body_edit,
1415 "declaration edit must bump decl_version (was {ver_after_body_edit}, now {ver_after_decl_edit})"
1416 );
1417
1418 let analysis_b_stale = store.cached_analysis_if_fresh(&ub);
1420 assert!(
1421 analysis_b_stale.is_none(),
1422 "B's analysis should be stale after A's declaration changed"
1423 );
1424 }
1425
1426 #[test]
1430 fn snapshot_query_survives_concurrent_writes() {
1431 use std::sync::Arc;
1432 use std::thread;
1433 use std::time::{Duration, Instant};
1434
1435 let store = Arc::new(DocumentStore::new());
1436 let u = uri("/sq_test.php");
1437 open(
1438 &store,
1439 u.clone(),
1440 "<?php\nfunction f(): int { return 1; }".to_string(),
1441 );
1442
1443 let deadline = Instant::now() + Duration::from_millis(200);
1444 let mut handles = Vec::new();
1445
1446 {
1448 let store = Arc::clone(&store);
1449 let u = u.clone();
1450 handles.push(thread::spawn(move || {
1451 let mut rev = 0u32;
1452 while Instant::now() < deadline {
1453 store.mirror_text(&u, &format!("<?php\nfunction f(): int {{ return {rev}; }}"));
1454 rev += 1;
1455 }
1456 }));
1457 }
1458
1459 for _ in 0..4 {
1461 let store = Arc::clone(&store);
1462 let u = u.clone();
1463 handles.push(thread::spawn(move || {
1464 while Instant::now() < deadline {
1465 let _ = store.get_doc_salsa(&u);
1466 let _ = store.get_index_salsa(&u);
1467 }
1468 }));
1469 }
1470
1471 for h in handles {
1472 h.join()
1473 .expect("no panic in snapshot_query under concurrent writes");
1474 }
1475 }
1476
1477 #[test]
1481 fn owned_program_cache_reused_after_sibling_declaration_change() {
1482 let store = DocumentStore::new();
1483 let ua = uri("/prog_a.php");
1484 let ub = uri("/prog_b.php");
1485
1486 open(
1487 &store,
1488 ua.clone(),
1489 "<?php\nfunction alpha(): void {}".to_string(),
1490 );
1491 open(
1492 &store,
1493 ub.clone(),
1494 "<?php\nfunction beta(): void {}".to_string(),
1495 );
1496
1497 let _ = store.cached_analysis(&ua);
1499 let _ = store.cached_analysis(&ub);
1500
1501 let prog_b_first = store
1503 .caches
1504 .owned_program_cache
1505 .get(&ub)
1506 .map(|e| Arc::clone(&e.1))
1507 .expect("B's owned_program should be cached after first analysis");
1508
1509 store.mirror_text(&ua, "<?php\nfunction alpha_renamed(): void {}");
1511 let _ = store.cached_analysis(&ua);
1513
1514 let _ = store.cached_analysis(&ub);
1516
1517 let prog_b_second = store
1518 .caches
1519 .owned_program_cache
1520 .get(&ub)
1521 .map(|e| Arc::clone(&e.1))
1522 .expect("B's owned_program should still be cached after sibling edit");
1523
1524 assert!(
1525 Arc::ptr_eq(&prog_b_first, &prog_b_second),
1526 "B's owned_program Arc should be identical (cache hit) after sibling declaration change"
1527 );
1528 }
1529}