1use std::sync::atomic::{AtomicBool, AtomicU64, 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 index_ready: AtomicBool,
68 write_revision: AtomicU64,
74 reanalyze_cancel: Mutex<mir_analyzer::IndexCancel>,
79}
80
81impl Default for DocumentStore {
82 fn default() -> Self {
83 Self::new()
84 }
85}
86
87impl DocumentStore {
88 pub fn new() -> Self {
89 DocumentStore {
90 caches: CacheRegistry::new(),
91 lsp_ws_files: DashMap::new(),
92 deleted_uris: DashSet::new(),
93 workspace_files_dirty: AtomicBool::new(true),
94 lsp_workspace: Mutex::new(None),
95 php_version: Mutex::new(mir_analyzer::PhpVersion::LATEST),
96 psr4: Arc::new(ArcSwap::from_pointee(Psr4Map::empty())),
97 analysis_session: Mutex::new(None),
98 session_cache_dir: OnceLock::new(),
99 autoload_uris: std::sync::RwLock::new(Vec::new()),
100 index_ready: AtomicBool::new(false),
101 write_revision: AtomicU64::new(0),
102 reanalyze_cancel: Mutex::new(mir_analyzer::IndexCancel::new()),
103 }
104 }
105
106 pub fn begin_reanalyze(&self) -> mir_analyzer::IndexCancel {
112 let fresh = mir_analyzer::IndexCancel::new();
113 let mut guard = self.reanalyze_cancel.lock().unwrap();
114 guard.cancel();
115 *guard = fresh.clone();
116 fresh
117 }
118
119 pub fn mark_index_ready(&self) {
122 self.index_ready.store(true, Ordering::Release);
123 }
124
125 pub fn is_index_ready(&self) -> bool {
127 self.index_ready.load(Ordering::Acquire)
128 }
129
130 pub fn write_rev(&self) -> u64 {
135 self.write_revision.load(Ordering::Acquire)
136 }
137
138 pub fn set_session_cache_dir(&self, dir: std::path::PathBuf) {
142 let _ = self.session_cache_dir.set(dir);
143 }
144
145 pub fn set_autoload_uris(&self, uris: Vec<Url>) {
151 *self.autoload_uris.write().unwrap() = uris;
152 self.caches.evict_analysis_all();
153 }
154
155 fn get_or_build_session(
161 &self,
162 php_version: mir_analyzer::PhpVersion,
163 ) -> Arc<mir_analyzer::AnalysisSession> {
164 let mut guard = self.analysis_session.lock().unwrap();
165 if let Some((cached_ver, session)) = guard.as_ref()
166 && *cached_ver == php_version
167 {
168 return Arc::clone(session);
169 }
170 let resolver: Arc<dyn mir_analyzer::ClassResolver> = self.psr4.load_full();
174 let mut builder =
175 mir_analyzer::AnalysisSession::new(php_version).with_class_resolver(resolver);
176 if let Some(dir) = self.session_cache_dir.get() {
177 builder = builder.with_cache_dir(dir);
178 }
179 let session = Arc::new(builder);
180 *guard = Some((php_version, Arc::clone(&session)));
181 session
182 }
183
184 pub fn analysis_session(
190 &self,
191 php_version: mir_analyzer::PhpVersion,
192 ) -> Arc<mir_analyzer::AnalysisSession> {
193 let session = self.get_or_build_session(php_version);
194 session.ensure_all_stubs();
195 session
196 }
197
198 pub fn workspace_php_version(&self) -> mir_analyzer::PhpVersion {
200 *self.php_version.lock().unwrap()
201 }
202
203 pub fn class_subtype_urls(&self, class_fqn: &str) -> Vec<tower_lsp::lsp_types::Url> {
211 let session = self.analysis_session(self.workspace_php_version());
212 session
213 .subtype_files(class_fqn)
214 .into_iter()
215 .filter_map(|p| tower_lsp::lsp_types::Url::parse(&p).ok())
216 .collect()
217 }
218
219 pub fn psr4_arc(&self) -> Arc<ArcSwap<Psr4Map>> {
224 Arc::clone(&self.psr4)
225 }
226
227 fn input_durability(uri: &Url) -> salsa::Durability {
230 if uri.as_str().contains("/vendor/") {
231 salsa::Durability::HIGH
232 } else {
233 salsa::Durability::LOW
234 }
235 }
236
237 fn lsp_ws_file(&self, uri: &Url) -> Option<LspWsFile> {
239 if self.deleted_uris.contains(uri) {
240 return None;
241 }
242 self.lsp_ws_files.get(uri).map(|e| *e)
243 }
244
245 fn snapshot_mir_query<R>(&self, f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R) -> R {
254 use std::panic::AssertUnwindSafe;
255 let session = self.get_or_build_session(self.workspace_php_version());
256 loop {
261 let db = session.snapshot_db();
262 match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
263 Ok(r) => return r,
264 Err(_) => drop(db),
265 }
266 }
267 }
268
269 fn try_snapshot_mir_query<R>(
275 &self,
276 attempts: usize,
277 f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R,
278 ) -> Option<R> {
279 use std::panic::AssertUnwindSafe;
280 let session = self.get_or_build_session(self.workspace_php_version());
281 for _ in 0..attempts {
282 let db = session.snapshot_db();
283 match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
284 Ok(r) => return Some(r),
285 Err(_) => drop(db),
286 }
287 }
288 None
289 }
290
291 pub fn mirror_text(&self, uri: &Url, text: &str) {
296 if let Some(cached) = self.caches.text_cache.get(uri)
302 && **cached == *text
303 && !self.deleted_uris.contains(uri)
304 && self.lsp_ws_files.contains_key(uri)
305 {
306 return;
307 }
308 self.mirror_text_arc(uri, Arc::from(text))
309 }
310
311 pub fn mirror_text_arc(&self, uri: &Url, text_arc: Arc<str>) {
318 let dur = Self::input_durability(uri);
319 let path: Arc<str> = Arc::from(uri.as_str());
320 let session = self.get_or_build_session(self.workspace_php_version());
321 if let Some(wf) = self.lsp_ws_files.get(uri).map(|e| *e) {
322 self.deleted_uris.remove(uri);
323 if let Some(cached) = self.caches.text_cache.get(uri)
326 && **cached == *text_arc
327 {
328 return;
329 }
330 session.with_db_mut(|db| {
331 let sf = wf.source(db);
332 sf.set_text(db).with_durability(dur).to(text_arc.clone());
333 if wf.cached_index(db).is_some() {
336 wf.set_cached_index(db).to(None);
337 }
338 });
339 self.caches.text_cache.insert(uri.clone(), text_arc);
340 self.caches.evict_analysis(uri);
343 self.write_revision.fetch_add(1, Ordering::Release);
344 } else {
345 let wf = session.with_db_mut(|db| {
346 let sf = db.upsert_source_file_with_durability(path, text_arc.clone(), dur);
347 LspWsFile::new(db, sf, None)
348 });
349 self.lsp_ws_files.insert(uri.clone(), wf);
350 self.caches.text_cache.insert(uri.clone(), text_arc);
351 self.workspace_files_dirty.store(true, Ordering::Release);
352 self.write_revision.fetch_add(1, Ordering::Release);
353 }
354 }
355
356 #[cfg(test)]
358 pub fn source_file(&self, uri: &Url) -> Option<LspWsFile> {
359 if self.deleted_uris.contains(uri) {
360 return None;
361 }
362 self.lsp_ws_files.get(uri).map(|e| *e)
363 }
364
365 pub fn seed_cached_index(&self, uri: &Url, index: Arc<FileIndex>) -> bool {
378 let Some(wf) = self.lsp_ws_file(uri) else {
379 return false;
380 };
381 let session = self.get_or_build_session(self.workspace_php_version());
382 session.with_db_mut(|db| wf.set_cached_index(db).to(Some(index)));
383 true
384 }
385
386 pub fn evict_token_cache(&self, uri: &Url) {
390 self.caches.evict_tokens(uri);
391 }
392
393 #[cfg(test)]
399 pub fn source_files_len(&self) -> usize {
400 self.lsp_ws_files.len()
401 }
402
403 #[cfg(test)]
404 pub fn snapshot_query_file_index(
405 &self,
406 uri: &Url,
407 ) -> Option<crate::index::file_index::FileIndex> {
408 let wf = self.lsp_ws_file(uri)?;
409 Some(
410 self.snapshot_mir_query(move |db| {
411 (*crate::db::mir_queries::file_index(db, wf).0).clone()
412 }),
413 )
414 }
415
416 pub fn ingest(&self, uri: Url, text: &str) {
421 self.mirror_text(&uri, text);
422 }
423
424 pub fn ingest_from_doc(&self, uri: Url, doc: &ParsedDoc) {
433 self.mirror_text_arc(&uri, doc.source_arc());
434 }
435
436 pub fn remove(&self, uri: &Url) {
437 self.caches.evict(uri);
438 self.deleted_uris.insert(uri.clone());
443 self.workspace_files_dirty.store(true, Ordering::Release);
444 self.sync_workspace_files();
447 let guard = self.analysis_session.lock().unwrap();
451 if let Some((_, session)) = guard.as_ref() {
452 session.invalidate_file(uri.as_str());
453 }
454 self.write_revision.fetch_add(1, Ordering::Release);
455 }
456
457 pub fn get_doc_salsa(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
470 self.get_parsed_cached(uri)
471 }
472
473 pub fn get_index_salsa(&self, uri: &Url) -> Option<Arc<FileIndex>> {
475 let wf = self.lsp_ws_file(uri)?;
476 Some(
477 self.snapshot_mir_query(move |db| crate::db::mir_queries::file_index(db, wf).0.clone()),
478 )
479 }
480
481 pub fn get_symbol_map_salsa(
484 &self,
485 uri: &Url,
486 ) -> Option<Arc<crate::types::symbol_map::SymbolMap>> {
487 let wf = self.lsp_ws_file(uri)?;
489 Some(self.snapshot_mir_query(move |db| {
490 let sf = wf.source(db);
491 crate::db::mir_queries::symbol_map(db, sf).0.clone()
492 }))
493 }
494
495 pub fn other_symbol_maps(
497 &self,
498 uri: &Url,
499 open_urls: &[Url],
500 ) -> Vec<(Url, Arc<crate::types::symbol_map::SymbolMap>)> {
501 open_urls
502 .iter()
503 .filter(|u| *u != uri)
504 .filter_map(|u| self.get_symbol_map_salsa(u).map(|m| (u.clone(), m)))
505 .collect()
506 }
507
508 fn get_parsed_cached(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
515 if let Some(current_text) = self.caches.text_cache.get(uri)
516 && let Some(entry) = self.caches.parsed_cache.get(uri)
517 && Arc::ptr_eq(&*current_text, &entry.0)
518 {
519 let doc = entry.1.clone();
520 drop(entry);
521 self.caches.touch(uri);
522 return Some(doc);
523 }
524
525 let wf = self.lsp_ws_file(uri)?;
527 self.caches.bump_parse_count();
528 let (text, doc) = self.snapshot_mir_query(move |db| {
529 let sf = wf.source(db);
530 let text = sf.text(db);
531 let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
532 (text, doc)
533 });
534 self.caches.insert_parsed(uri.clone(), text, doc.clone());
535 Some(doc)
536 }
537
538 pub fn get_doc_snapshot_or_stale(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
546 if let Some(current_text) = self.caches.text_cache.get(uri)
547 && let Some(entry) = self.caches.parsed_cache.get(uri)
548 && Arc::ptr_eq(&*current_text, &entry.0)
549 {
550 let doc = entry.1.clone();
551 drop(entry);
552 self.caches.touch(uri);
553 return Some(doc);
554 }
555
556 if let Some(wf) = self.lsp_ws_file(uri)
557 && let Some((text, doc)) = self.try_snapshot_mir_query(3, move |db| {
558 let sf = wf.source(db);
559 let text = sf.text(db);
560 let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
561 (text, doc)
562 })
563 {
564 self.caches.bump_parse_count();
565 self.caches.insert_parsed(uri.clone(), text, doc.clone());
566 return Some(doc);
567 }
568
569 self.caches.parsed_cache.get(uri).map(|e| e.1.clone())
570 }
571
572 pub fn sync_workspace_files(&self) {
577 if !self.workspace_files_dirty.swap(false, Ordering::AcqRel) {
580 return;
581 }
582
583 let mut entries: Vec<(Arc<str>, LspWsFile)> = self
585 .lsp_ws_files
586 .iter()
587 .filter(|e| !self.deleted_uris.contains(e.key()))
588 .map(|e| (Arc::<str>::from(e.key().as_str()), *e.value()))
589 .collect();
590 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
591 let files: Arc<[LspWsFile]> = entries.iter().map(|(_, wf)| *wf).collect();
592
593 let session = self.get_or_build_session(self.workspace_php_version());
594 let mut guard = self.lsp_workspace.lock().unwrap();
595 session.with_db_mut(|db| match *guard {
596 Some(ws) => {
597 ws.set_files(db).to(files);
598 }
599 None => *guard = Some(LspWorkspace::new(db, files)),
600 });
601 }
602
603 pub fn mark_workspace_files_dirty(&self) {
607 self.workspace_files_dirty.store(true, Ordering::Release);
608 }
609
610 pub fn set_php_version(&self, version: mir_analyzer::PhpVersion) {
615 {
616 let mut guard = self.php_version.lock().unwrap();
617 if *guard == version {
618 return;
619 }
620 *guard = version;
621 }
622 self.lsp_ws_files.clear();
627 *self.lsp_workspace.lock().unwrap() = None;
628 self.workspace_files_dirty.store(true, Ordering::Release);
629 self.caches.evict_analysis_all();
631 }
632
633 pub fn session_references_to(
647 &self,
648 symbol: &mir_analyzer::Name,
649 files: &[Arc<str>],
650 cancel_rev: Option<u64>,
651 ) -> Vec<(Arc<str>, u32, u32, u32)> {
652 let php_version = self.workspace_php_version();
653 let stale =
659 || cancel_rev.is_some_and(|rev| self.write_revision.load(Ordering::Acquire) != rev);
660 let raw = loop {
664 let session = self.analysis_session(php_version);
665 match salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
666 session.references_to_in_files_cancellable(symbol, files, &stale)
667 })) {
668 Ok(Some(refs)) => break refs,
669 Ok(None) => {
674 std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
675 }
676 Err(_) if stale() => {
677 std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
678 }
679 Err(_) => {}
683 }
684 };
685 raw.into_iter()
686 .map(|(file, range)| {
687 let line = range.start.line.saturating_sub(1);
689 let col_start = range.start.column;
690 let col_end = range.end.column;
691 (file, line, col_start, col_end)
692 })
693 .collect()
694 }
695
696 pub fn method_reference_scope(&self, owner_fqn: &str, method: &str) -> Option<Vec<Url>> {
720 use crate::index::file_index::{ClassKind, Visibility};
721
722 let ws = self.get_workspace_index_salsa();
723 let owner_fqn = owner_fqn.trim_start_matches('\\');
724 let owner_short = crate::text::fqn_short_name(owner_fqn);
725
726 let (decl_uri, decl_class) = ws
727 .classes_by_name
728 .get(owner_short)?
729 .iter()
730 .filter_map(|&r| ws.at(r))
731 .find(|(_, cls)| cls.fqn.trim_start_matches('\\') == owner_fqn)?;
732
733 if !matches!(decl_class.kind, ClassKind::Class | ClassKind::Enum)
736 || !decl_class.traits.is_empty()
737 || !decl_class.mixins.is_empty()
738 {
739 return None;
740 }
741
742 let vis = decl_class
743 .methods
744 .iter()
745 .find(|m| m.name.eq_ignore_ascii_case(method))?
746 .visibility;
747
748 match vis {
749 Visibility::Private => Some(vec![decl_uri.clone()]),
750 Visibility::Protected => {
751 if !self.is_index_ready() {
752 return None;
753 }
754 let session = self.analysis_session(self.workspace_php_version());
759 let mut files: std::collections::HashSet<Url> = session
760 .subtype_files(owner_fqn)
761 .into_iter()
762 .filter_map(|p| Url::parse(&p).ok())
763 .collect();
764 files.insert(decl_uri.clone());
765 Some(files.into_iter().collect())
766 }
767 Visibility::Public => None,
768 }
769 }
770
771 pub fn get_workspace_index_salsa(&self) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
781 self.sync_workspace_files();
782 let ws = *self.lsp_workspace.lock().unwrap();
783 let Some(ws) = ws else {
784 return Arc::new(crate::db::workspace_index::WorkspaceIndexData {
785 files: Vec::new(),
786 classes_by_name: std::collections::HashMap::new(),
787 subtypes_of: std::collections::HashMap::new(),
788 decls_by_name: std::collections::HashMap::new(),
789 classes_by_lowercase_name: Vec::new(),
790 });
791 };
792 self.snapshot_mir_query(move |db| crate::db::mir_queries::workspace_index(db, ws).0.clone())
793 }
794
795 pub fn parse_count(&self) -> u64 {
799 self.caches.parse_count()
800 }
801
802 pub fn source_text(&self, uri: &Url) -> Option<Arc<str>> {
806 self.caches.text_cache.get(uri).map(|e| Arc::clone(&e))
807 }
808
809 pub fn store_token_cache(&self, uri: &Url, result_id: String, tokens: Arc<Vec<SemanticToken>>) {
812 self.caches.store_token(uri, result_id, tokens);
813 }
814
815 pub fn get_token_cache(&self, uri: &Url, result_id: &str) -> Option<Arc<Vec<SemanticToken>>> {
817 self.caches.get_token(uri, result_id)
818 }
819
820 #[tracing::instrument(skip_all)]
826 pub fn get_semantic_issues_salsa(&self, uri: &Url) -> Option<Arc<[mir_issues::Issue]>> {
827 let analysis = self.cached_analysis(uri)?;
828 let file: Arc<str> = Arc::from(uri.as_str());
829 let class_issues = {
835 let _s = tracing::debug_span!("session.class_issues_for").entered();
836 loop {
838 let session = self.analysis_session(self.workspace_php_version());
839 if let Ok(issues) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
840 session.class_issues(std::slice::from_ref(&file))
841 })) {
842 break issues;
843 }
844 }
845 };
846 let combined: Vec<mir_issues::Issue> = analysis
847 .issues
848 .iter()
849 .cloned()
850 .chain(class_issues)
851 .filter(|i| !i.suppressed)
852 .collect();
853 Some(Arc::from(combined))
854 }
855
856 pub fn cached_type_map(
871 &self,
872 uri: &Url,
873 doc: &crate::document::ast::ParsedDoc,
874 meta: Option<&crate::lang::phpstorm_meta::PhpStormMeta>,
875 ) -> Arc<crate::types::type_map::TypeMap> {
876 let source = doc.source_arc();
877 let meta_key = meta.map_or(0usize, |m| std::ptr::from_ref(m) as usize);
878 if let Some(entry) = self.caches.type_map_cache.get(uri)
879 && Arc::ptr_eq(&entry.0, &source)
880 && entry.1 == meta_key
881 {
882 let map = Arc::clone(&entry.2);
883 drop(entry);
884 self.caches.touch(uri);
885 return map;
886 }
887 let map = Arc::new(crate::types::type_map::TypeMap::from_doc_with_meta(
888 doc, meta,
889 ));
890 self.caches.shed_stale(
891 &self.caches.type_map_cache,
892 crate::document::cache_registry::TYPE_MAP_CACHE_CAP,
893 );
894 self.caches.touch(uri);
895 self.caches
896 .type_map_cache
897 .insert(uri.clone(), (source, meta_key, Arc::clone(&map)));
898 map
899 }
900
901 pub fn cached_analysis_if_fresh(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
907 let doc = self.get_doc_salsa(uri)?;
908 let source = doc.source_arc();
909 let cur_ver = self.caches.decl_version();
910 let analysis = {
911 let entry = self.caches.analysis_cache.get(uri)?;
912 (Arc::ptr_eq(&entry.0, &source) && entry.1 == cur_ver).then(|| Arc::clone(&entry.2))?
913 };
914 self.caches.touch(uri);
915 Some(analysis)
916 }
917
918 #[tracing::instrument(skip_all)]
919 pub fn cached_analysis(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
920 let doc = self.get_doc_salsa(uri)?;
922 let source = doc.source_arc();
923
924 let cur_ver = self.caches.decl_version();
925 if let Some(entry) = self.caches.analysis_cache.get(uri)
926 && Arc::ptr_eq(&entry.0, &source)
927 && entry.1 == cur_ver
928 {
929 let analysis = Arc::clone(&entry.2);
930 drop(entry);
931 self.caches.touch(uri);
932 return Some(analysis);
933 }
934
935 let php_version = self.workspace_php_version();
936 let session = self.analysis_session(php_version);
937 let file: Arc<str> = Arc::from(uri.as_str());
938
939 let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
940 let owned_program = if let Some(cached) = self.caches.owned_program_cache.get(uri)
941 && Arc::ptr_eq(&cached.0, &source)
942 {
943 Arc::clone(&cached.1)
944 } else {
945 let prog = Arc::new(php_ast::owned::to_owned_program(doc.program()));
946 self.caches.shed_stale(
947 &self.caches.owned_program_cache,
948 crate::document::cache_registry::OWNED_PROGRAM_CACHE_CAP,
949 );
950 self.caches
951 .owned_program_cache
952 .insert(uri.clone(), (Arc::clone(&source), Arc::clone(&prog)));
953 prog
954 };
955
956 let autoload_texts: Vec<(Arc<str>, Arc<str>)> = {
958 let autoload_uris = self.autoload_uris.read().unwrap().clone();
959 autoload_uris
960 .iter()
961 .filter_map(|auri| {
962 self.caches
963 .text_cache
964 .get(auri)
965 .map(|t| (Arc::from(auri.as_str()), Arc::clone(&*t)))
966 })
967 .collect()
968 };
969 let class_fqns = crate::references::collect_referenced_class_fqns(&doc);
971
972 let analysis = loop {
974 let attempt = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
975 session.ingest_file(file.clone(), source.clone());
976 for (afile, atext) in &autoload_texts {
977 session.ingest_file(afile.clone(), atext.clone());
978 }
979 for fqcn in &class_fqns {
980 let _ = session.load_class(fqcn);
981 }
982 let analyzer = mir_analyzer::FileAnalyzer::new(&session);
983 analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map)
984 }));
985 if let Ok(a) = attempt {
986 break Arc::new(a);
987 }
988 };
989 let new_index = self.get_index_salsa(uri);
995 let old_fp = self
996 .caches
997 .decl_fingerprints
998 .get(uri)
999 .map(|e| Arc::clone(&*e));
1000 let decl_changed = match (&old_fp, &new_index) {
1001 (Some(old), Some(new)) => **old != **new,
1002 (None, Some(_)) => true,
1003 _ => false,
1004 };
1005 if decl_changed {
1006 if let Some(idx) = new_index {
1007 self.caches.decl_fingerprints.insert(uri.clone(), idx);
1008 }
1009 self.caches.bump_decl_version();
1010 session.bump_prepare_generation();
1015 }
1016 let ver = self.caches.decl_version();
1017 self.caches.shed_stale(
1018 &self.caches.analysis_cache,
1019 crate::document::cache_registry::ANALYSIS_CACHE_CAP,
1020 );
1021 self.caches.touch(uri);
1022 self.caches
1023 .analysis_cache
1024 .insert(uri.clone(), (source, ver, Arc::clone(&analysis)));
1025 Some(analysis)
1026 }
1027
1028 pub fn docs_for(&self, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
1033 open_urls
1034 .iter()
1035 .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
1036 .collect()
1037 }
1038
1039 pub fn doc_with_others(
1042 &self,
1043 uri: &Url,
1044 doc: Arc<ParsedDoc>,
1045 open_urls: &[Url],
1046 ) -> Vec<(Url, Arc<ParsedDoc>)> {
1047 let mut result = vec![(uri.clone(), doc)];
1048 result.extend(self.other_docs(uri, open_urls));
1049 result
1050 }
1051
1052 pub fn other_docs(&self, uri: &Url, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
1054 open_urls
1055 .iter()
1056 .filter(|u| *u != uri)
1057 .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
1058 .collect()
1059 }
1060
1061 pub fn all_indexes(&self) -> Vec<(Url, Arc<FileIndex>)> {
1063 self.get_workspace_index_salsa().files.clone()
1064 }
1065
1066 pub fn cache_vendor_index(&self, uri: Url, index: Arc<FileIndex>) {
1070 self.caches.vendor_index_cache.insert(uri, index);
1071 }
1072
1073 pub fn get_vendor_index(&self, uri: &Url) -> Option<Arc<FileIndex>> {
1075 self.caches
1076 .vendor_index_cache
1077 .get(uri)
1078 .map(|e| Arc::clone(&*e))
1079 }
1080
1081 pub fn other_indexes(&self, uri: &Url) -> Vec<(Url, Arc<FileIndex>)> {
1083 self.get_workspace_index_salsa()
1084 .files
1085 .iter()
1086 .filter(|(u, _)| u != uri)
1087 .cloned()
1088 .collect()
1089 }
1090
1091 pub fn all_docs_for_scan(&self) -> Vec<(Url, Arc<ParsedDoc>)> {
1095 let urls: Vec<Url> = self
1096 .lsp_ws_files
1097 .iter()
1098 .filter(|e| !self.deleted_uris.contains(e.key()))
1099 .map(|e| e.key().clone())
1100 .collect();
1101 urls.into_iter()
1102 .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
1103 .collect()
1104 }
1105
1106 pub fn candidate_urls_for(&self, word: &str) -> Vec<Url> {
1117 use rayon::prelude::*;
1118 let urls: Vec<Url> = self
1119 .lsp_ws_files
1120 .iter()
1121 .filter(|e| !self.deleted_uris.contains(e.key()))
1122 .map(|e| e.key().clone())
1123 .collect();
1124 let finder = memchr::memmem::Finder::new(word.as_bytes());
1125 let wlen = word.len();
1126 urls.into_par_iter()
1127 .filter(|u| match self.caches.text_cache.get(u) {
1128 Some(src) => mentions_identifier(src.as_bytes(), &finder, wlen),
1129 None => true,
1130 })
1131 .collect()
1132 }
1133
1134 pub fn candidate_docs_for(&self, word: &str) -> Vec<(Url, Arc<ParsedDoc>)> {
1144 self.candidate_urls_for(word)
1145 .into_iter()
1146 .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
1147 .collect()
1148 }
1149}
1150
1151fn mentions_identifier(hay: &[u8], finder: &memchr::memmem::Finder, wlen: usize) -> bool {
1157 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1158 let mut from = 0;
1159 while let Some(rel) = finder.find(&hay[from..]) {
1160 let idx = from + rel;
1161 let before_ok = idx == 0 || !is_ident(hay[idx - 1]);
1162 let end = idx + wlen;
1163 let after_ok = end >= hay.len() || !is_ident(hay[end]);
1164 if before_ok && after_ok {
1165 return true;
1166 }
1167 from = idx + 1;
1168 }
1169 false
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174 use super::*;
1175
1176 fn uri(path: &str) -> Url {
1177 Url::parse(&format!("file://{path}")).unwrap()
1178 }
1179
1180 #[test]
1181 fn mentions_identifier_respects_word_boundaries() {
1182 let m = |hay: &str, word: &str| {
1183 let f = memchr::memmem::Finder::new(word.as_bytes());
1184 mentions_identifier(hay.as_bytes(), &f, word.len())
1185 };
1186 assert!(m("$this->save();", "save"));
1187 assert!(m("function save() {}", "save"));
1188 assert!(m("save", "save"));
1189 assert!(!m("$this->saveAll();", "save"));
1191 assert!(!m("return $unsaved;", "save"));
1192 assert!(!m("class Saver {}", "save"));
1193 assert!(!m("no occurrence here", "save"));
1194 }
1195
1196 fn open(store: &DocumentStore, u: Url, text: String) {
1201 store.mirror_text(&u, &text);
1202 }
1203
1204 #[test]
1209 fn index_registers_file_in_salsa() {
1210 let store = DocumentStore::new();
1211 store.ingest(uri("/lib.php"), "<?php\nfunction lib_fn() {}");
1212 let idx = store.get_index_salsa(&uri("/lib.php")).unwrap();
1213 assert_eq!(idx.functions.len(), 1);
1214 assert_eq!(&*idx.functions[0].name, "lib_fn");
1215 }
1216
1217 #[test]
1218 fn remove_hides_file_from_index() {
1219 let store = DocumentStore::new();
1220 let u = uri("/lib.php");
1221 store.ingest(u.clone(), "<?php");
1222 store.remove(&u);
1223 assert!(store.get_index_salsa(&u).is_none());
1224 }
1225
1226 #[test]
1227 fn remove_and_reopen_reuses_source_file_handle() {
1228 let store = DocumentStore::new();
1229 let u = uri("/lib.php");
1230 store.ingest(u.clone(), "<?php");
1231 let ft_before = store.source_file(&u).unwrap();
1232 store.remove(&u);
1233 assert!(
1234 store.source_file(&u).is_none(),
1235 "deleted file should be hidden"
1236 );
1237 store.mirror_text(&u, "<?php");
1238 let ft_after = store.source_file(&u).unwrap();
1239 assert!(
1240 ft_before == ft_after,
1241 "reopen must reuse the same FileText handle"
1242 );
1243 }
1244
1245 #[test]
1246 fn delete_reopen_churn_does_not_amplify_salsa_inputs() {
1247 let store = DocumentStore::new();
1248 let uris: Vec<Url> = (0..20).map(|i| uri(&format!("/churn/f{i}.php"))).collect();
1249 for u in &uris {
1250 store.ingest(u.clone(), "<?php class A {}");
1251 }
1252 let count_before = store.source_files_len();
1253 for _ in 0..10 {
1254 for u in &uris {
1255 store.remove(u);
1256 }
1257 for u in &uris {
1258 store.ingest(u.clone(), "<?php class A {}");
1259 }
1260 }
1261 assert_eq!(
1262 store.source_files_len(),
1263 count_before,
1264 "delete-reopen cycles must not create new salsa inputs (L1-B regression guard)"
1265 );
1266 }
1267
1268 #[test]
1269 fn all_indexes_includes_every_mirrored_file() {
1270 let store = DocumentStore::new();
1271 open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1272 store.ingest(uri("/b.php"), "<?php\nfunction b() {}");
1273 assert_eq!(store.all_indexes().len(), 2);
1274 }
1275
1276 #[test]
1277 fn other_indexes_excludes_current_uri() {
1278 let store = DocumentStore::new();
1279 open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1280 open(&store, uri("/b.php"), "<?php\nfunction b() {}".to_string());
1281 assert_eq!(store.other_indexes(&uri("/a.php")).len(), 1);
1282 }
1283
1284 #[test]
1285 fn other_docs_excludes_current_uri() {
1286 let store = DocumentStore::new();
1287 let ua = uri("/a.php");
1288 let ub = uri("/b.php");
1289 open(&store, ua.clone(), "<?php\nfunction a() {}".to_string());
1290 open(&store, ub.clone(), "<?php\nfunction b() {}".to_string());
1291 let open_urls = vec![ua.clone(), ub];
1292 assert_eq!(store.other_docs(&ua, &open_urls).len(), 1);
1293 }
1294
1295 #[test]
1296 fn evict_token_cache_removes_entry() {
1297 let store = DocumentStore::new();
1298 let u = uri("/a.php");
1299 open(&store, u.clone(), "<?php".to_string());
1300 store.store_token_cache(&u, "id1".to_string(), Arc::new(vec![]));
1301 assert!(store.get_token_cache(&u, "id1").is_some());
1302 store.evict_token_cache(&u);
1303 assert!(store.get_token_cache(&u, "id1").is_none());
1304 }
1305
1306 #[test]
1307 fn vendor_index_cache_evicted_on_remove() {
1308 let store = DocumentStore::new();
1309 let u = uri("/vendor/acme/lib.php");
1310 store.ingest(u.clone(), "<?php\nclass Lib {}");
1311 let idx = store.get_index_salsa(&u).unwrap();
1312 store.cache_vendor_index(u.clone(), idx.clone());
1313 assert!(store.get_vendor_index(&u).is_some());
1314 store.remove(&u);
1315 assert!(store.get_vendor_index(&u).is_none());
1316 }
1317
1318 #[test]
1319 fn index_populates_file_index_with_symbols() {
1320 let store = DocumentStore::new();
1321 store.ingest(uri("/a.php"), "<?php\nfunction hello() {}");
1322 let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1323 assert_eq!(idx.functions.len(), 1);
1324 assert_eq!(&*idx.functions[0].name, "hello");
1325 }
1326
1327 #[test]
1328 fn open_populates_file_index_with_symbols() {
1329 let store = DocumentStore::new();
1330 open(&store, uri("/a.php"), "<?php\nclass Foo {}".to_string());
1331 let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1332 assert_eq!(idx.classes.len(), 1);
1333 assert_eq!(&*idx.classes[0].name, "Foo");
1334 }
1335
1336 fn names_of(idx: &FileIndex) -> Vec<String> {
1343 let mut out: Vec<String> = idx.classes.iter().map(|c| c.name.to_string()).collect();
1344 out.extend(idx.functions.iter().map(|f| f.name.to_string()));
1345 out.sort();
1346 out
1347 }
1348
1349 fn salsa_index_names(store: &DocumentStore, url: &Url) -> Vec<String> {
1350 store
1351 .snapshot_query_file_index(url)
1352 .map(|idx| names_of(&idx))
1353 .unwrap_or_default()
1354 }
1355
1356 #[test]
1357 fn mirror_tracks_repeated_edits() {
1358 let store = DocumentStore::new();
1359 let u = uri("/mirror.php");
1360
1361 open(&store, u.clone(), "<?php\nclass A {}".to_string());
1362 assert_eq!(salsa_index_names(&store, &u), vec!["A".to_string()]);
1363
1364 open(
1365 &store,
1366 u.clone(),
1367 "<?php\nclass A {}\nclass B {}".to_string(),
1368 );
1369 assert_eq!(
1370 salsa_index_names(&store, &u),
1371 vec!["A".to_string(), "B".to_string()]
1372 );
1373
1374 open(&store, u.clone(), "<?php\nfunction greet() {}".to_string());
1375 assert_eq!(salsa_index_names(&store, &u), vec!["greet".to_string()]);
1376 }
1377
1378 #[test]
1379 fn mirror_tracks_ingest_and_ingest_from_doc() {
1380 let store = DocumentStore::new();
1381
1382 let u1 = uri("/bg1.php");
1384 store.ingest(u1.clone(), "<?php\nclass Bg1 {}");
1385 assert_eq!(salsa_index_names(&store, &u1), vec!["Bg1".to_string()]);
1386
1387 let u2 = uri("/bg2.php");
1389 let doc = crate::analysis::diagnostics::parse_document_no_diags(
1390 "<?php\nclass Bg2 {}\nfunction f() {}",
1391 );
1392 store.ingest_from_doc(u2.clone(), &doc);
1393 assert_eq!(
1394 salsa_index_names(&store, &u2),
1395 vec!["Bg2".to_string(), "f".to_string()]
1396 );
1397 }
1398
1399 #[test]
1410 fn seed_cached_index_noops_for_unknown_uri() {
1411 let store = DocumentStore::new();
1412 let u = uri("/never_mirrored.php");
1413 let index = Arc::new(crate::index::file_index::FileIndex::default());
1414 assert!(!store.seed_cached_index(&u, index));
1415 }
1416
1417 #[test]
1419 fn parsed_cache_stays_bounded_under_many_inserts() {
1420 let store = DocumentStore::new();
1421 use crate::document::cache_registry::PARSED_CACHE_CAP;
1422 let overflow = PARSED_CACHE_CAP + 100;
1423 for i in 0..overflow {
1424 let u = uri(&format!("/cap/file{i}.php"));
1425 store.ingest(u.clone(), "<?php\nclass A {}");
1426 let _ = store.get_doc_salsa(&u);
1428 }
1429 assert!(
1430 store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1431 "parsed_cache grew to {} entries (cap {})",
1432 store.caches.parsed_cache.len(),
1433 PARSED_CACHE_CAP
1434 );
1435 }
1436
1437 #[test]
1441 fn parsed_cache_shed_keeps_recently_used_entries() {
1442 let store = DocumentStore::new();
1443 use crate::document::cache_registry::PARSED_CACHE_CAP;
1444
1445 let hot = uri("/cap/hot.php");
1446 store.ingest(hot.clone(), "<?php\nclass Hot {}");
1447 let _ = store.get_doc_salsa(&hot);
1448
1449 for i in 0..(PARSED_CACHE_CAP - 2) {
1452 let u = uri(&format!("/cap/cold{i}.php"));
1453 store.ingest(u.clone(), "<?php\nclass A {}");
1454 let _ = store.get_doc_salsa(&u);
1455 }
1456 let _ = store.get_doc_salsa(&hot);
1457
1458 let trigger = uri("/cap/trigger.php");
1460 store.ingest(trigger.clone(), "<?php\nclass T {}");
1461 let _ = store.get_doc_salsa(&trigger);
1462
1463 assert!(
1464 store.caches.parsed_cache.contains_key(&hot),
1465 "recently-touched entry must survive the recency shed"
1466 );
1467 assert!(
1468 store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1469 "cache must stay bounded after the shed"
1470 );
1471 }
1472
1473 #[test]
1477 fn analysis_cache_stays_bounded_under_many_files() {
1478 use crate::document::cache_registry::ANALYSIS_CACHE_CAP;
1479 let store = DocumentStore::new();
1480 let overflow = ANALYSIS_CACHE_CAP + 16;
1481 for i in 0..overflow {
1482 let u = uri(&format!("/acap/file{i}.php"));
1483 store.ingest(u.clone(), "<?php\nfunction f() { return 1; }");
1484 let _ = store.cached_analysis(&u);
1485 }
1486 assert!(
1487 store.caches.analysis_cache.len() <= ANALYSIS_CACHE_CAP,
1488 "analysis_cache grew to {} entries (cap {})",
1489 store.caches.analysis_cache.len(),
1490 ANALYSIS_CACHE_CAP
1491 );
1492 }
1493
1494 #[test]
1495 fn get_doc_salsa_cache_hits_across_calls() {
1496 let store = DocumentStore::new();
1497 let u = uri("/g3_cache.php");
1498 open(&store, u.clone(), "<?php\nclass G3 {}".to_string());
1499
1500 let a = store.get_doc_salsa(&u).unwrap();
1501 let b = store.get_doc_salsa(&u).unwrap();
1502 assert!(
1503 Arc::ptr_eq(&a, &b),
1504 "parsed_cache hit should yield the same Arc across calls"
1505 );
1506
1507 open(&store, u.clone(), "<?php\nclass G3b {}".to_string());
1508 let c = store.get_doc_salsa(&u).unwrap();
1509 assert!(
1510 !Arc::ptr_eq(&a, &c),
1511 "edit should invalidate the parsed_cache entry"
1512 );
1513 }
1514
1515 #[test]
1516 fn get_doc_salsa_returns_some_for_mirrored_files() {
1517 let store = DocumentStore::new();
1520 let u = uri("/e4_doc.php");
1521 store.ingest(u.clone(), "<?php\nclass P {}");
1522 assert!(store.get_doc_salsa(&u).is_some());
1523 }
1524
1525 #[test]
1526 fn get_doc_snapshot_or_stale_matches_salsa_on_settled_buffer() {
1527 let store = DocumentStore::new();
1528 let u = uri("/stale_settled.php");
1529 open(&store, u.clone(), "<?php\nclass S {}".to_string());
1530 let fresh = store.get_doc_salsa(&u).unwrap();
1531 let stale = store.get_doc_snapshot_or_stale(&u).unwrap();
1532 assert!(
1533 Arc::ptr_eq(&fresh, &stale),
1534 "on a settled buffer the stale-tolerant accessor must serve the cached parse"
1535 );
1536 }
1537
1538 #[test]
1542 fn get_doc_snapshot_or_stale_stays_responsive_under_writes() {
1543 use std::sync::Arc;
1544 use std::thread;
1545 use std::time::{Duration, Instant};
1546
1547 let store = Arc::new(DocumentStore::new());
1548 let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/hl{i}.php"))).collect();
1549 for (i, u) in urls.iter().enumerate() {
1550 open(&store, u.clone(), format!("<?php\nclass H{i} {{}}"));
1551 assert!(store.get_doc_salsa(u).is_some());
1553 }
1554
1555 let deadline = Instant::now() + Duration::from_millis(400);
1556 let mut handles = Vec::new();
1557
1558 {
1559 let store = Arc::clone(&store);
1560 let urls = urls.clone();
1561 handles.push(thread::spawn(move || {
1562 let mut rev = 0u32;
1563 while Instant::now() < deadline {
1564 for u in &urls {
1565 store.mirror_text(u, &format!("<?php\nclass H {{}}\n// rev {rev}"));
1566 }
1567 rev += 1;
1568 }
1569 }));
1570 }
1571
1572 for _ in 0..4 {
1573 let store = Arc::clone(&store);
1574 let urls = urls.clone();
1575 handles.push(thread::spawn(move || {
1576 while Instant::now() < deadline {
1577 for u in &urls {
1578 assert!(
1579 store.get_doc_snapshot_or_stale(u).is_some(),
1580 "open file must resolve to a fresh or stale parse, never None"
1581 );
1582 }
1583 }
1584 }));
1585 }
1586
1587 for h in handles {
1588 h.join()
1589 .expect("stale accessor must not panic or spin under concurrent writes");
1590 }
1591 }
1592
1593 #[test]
1598 #[ignore = "timing measurement; run with --ignored --nocapture"]
1599 fn ws1_ab_latency_under_write_pressure() {
1600 use std::sync::Arc;
1601 use std::sync::atomic::{AtomicBool, Ordering};
1602 use std::thread;
1603 use std::time::{Duration, Instant};
1604
1605 fn big_source(rev: u32) -> String {
1609 let mut s = String::from("<?php\nclass Big {\n");
1610 for i in 0..3000 {
1611 s.push_str(&format!(
1612 " public function m{i}(int $a, string $b): int {{ return $a + {i}; }}\n"
1613 ));
1614 }
1615 s.push_str(&format!("}}\n// rev {rev}\n"));
1616 s
1617 }
1618
1619 fn measure(
1620 store: &Arc<DocumentStore>,
1621 url: &Url,
1622 writers: usize,
1623 label: &str,
1624 call: impl Fn(&DocumentStore, &Url) -> bool,
1625 ) {
1626 let stop = Arc::new(AtomicBool::new(false));
1627 let mut writer_handles = Vec::new();
1628 for w in 0..writers {
1629 let store = Arc::clone(store);
1630 let url = url.clone();
1631 let stop = Arc::clone(&stop);
1632 writer_handles.push(thread::spawn(move || {
1633 let mut rev = (w as u32) * 1_000_000;
1634 while !stop.load(Ordering::Relaxed) {
1635 store.mirror_text(&url, &big_source(rev));
1636 rev = rev.wrapping_add(1);
1637 }
1638 }));
1639 }
1640 thread::sleep(Duration::from_millis(30)); let n = 100usize;
1643 let mut max = Duration::ZERO;
1644 let mut total = Duration::ZERO;
1645 for _ in 0..n {
1646 let t = Instant::now();
1647 let ok = call(store, url);
1648 let dt = t.elapsed();
1649 assert!(ok, "{label}: accessor returned None");
1650 max = max.max(dt);
1651 total += dt;
1652 }
1653 stop.store(true, Ordering::Relaxed);
1654 for h in writer_handles {
1655 h.join().unwrap();
1656 }
1657 println!(
1658 "{label}: {n} calls, {writers} writers — max {:>10.3?}, mean {:>10.3?}",
1659 max,
1660 total / n as u32
1661 );
1662 }
1663
1664 let store = Arc::new(DocumentStore::new());
1665 let url = uri("/perf_big.php");
1666 open(&store, url.clone(), big_source(0));
1667 assert!(store.get_doc_salsa(&url).is_some()); for writers in [2usize, 4] {
1670 measure(
1671 &store,
1672 &url,
1673 writers,
1674 "NEW get_doc_snapshot_or_stale",
1675 |s, u| s.get_doc_snapshot_or_stale(u).is_some(),
1676 );
1677 measure(
1678 &store,
1679 &url,
1680 writers,
1681 "OLD get_doc_salsa (unbounded) ",
1682 |s, u| s.get_doc_salsa(u).is_some(),
1683 );
1684 }
1685 }
1686
1687 #[test]
1688 fn get_salsa_accessors_return_none_for_unknown_uri() {
1689 let store = DocumentStore::new();
1690 let u = uri("/never-seen.php");
1691 assert!(store.get_doc_salsa(&u).is_none());
1692 assert!(store.get_index_salsa(&u).is_none());
1693 }
1694
1695 #[test]
1703 fn concurrent_reads_and_writes_do_not_panic() {
1704 use std::sync::Arc;
1705 use std::thread;
1706 use std::time::{Duration, Instant};
1707
1708 let store = Arc::new(DocumentStore::new());
1709 let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/f{i}.php"))).collect();
1710 for (i, u) in urls.iter().enumerate() {
1711 open(&store, u.clone(), format!("<?php\nclass C{i} {{}}"));
1712 }
1713
1714 let deadline = Instant::now() + Duration::from_millis(400);
1715 let mut handles = Vec::new();
1716
1717 {
1719 let store = Arc::clone(&store);
1720 let urls = urls.clone();
1721 handles.push(thread::spawn(move || {
1722 let mut rev = 0u32;
1723 while Instant::now() < deadline {
1724 for u in &urls {
1725 let text = format!("<?php\nclass C{{}}\n// rev {rev}");
1726 store.mirror_text(u, &text);
1727 }
1728 rev += 1;
1729 }
1730 }));
1731 }
1732
1733 for _ in 0..4 {
1735 let store = Arc::clone(&store);
1736 let urls = urls.clone();
1737 handles.push(thread::spawn(move || {
1738 while Instant::now() < deadline {
1739 for u in &urls {
1740 let _ = store.get_doc_salsa(u);
1741 let _ = store.get_index_salsa(u);
1742 }
1743 }
1747 }));
1748 }
1749
1750 for h in handles {
1751 h.join().expect("no panic under concurrent read/write");
1752 }
1753 }
1754
1755 #[test]
1759 fn psr4_lazy_load_suppresses_undefined_class() {
1760 let tmp = tempfile::tempdir().unwrap();
1761
1762 std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
1764 std::fs::write(
1765 tmp.path().join("src/Model/Entity.php"),
1766 "<?php\nnamespace App\\Model;\nclass Entity {}\n",
1767 )
1768 .unwrap();
1769
1770 std::fs::write(
1772 tmp.path().join("composer.json"),
1773 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1774 )
1775 .unwrap();
1776
1777 let store = DocumentStore::new();
1778
1779 store
1781 .psr4
1782 .store(Arc::new(crate::lang::autoload::Psr4Map::load(tmp.path())));
1783
1784 let handler_url = Url::from_file_path(tmp.path().join("src/Service/Handler.php")).unwrap();
1788 store.mirror_text(
1789 &handler_url,
1790 "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\nfunction handle(Entity $e): Entity { return $e; }\n",
1791 );
1792
1793 let issues = store.get_semantic_issues_salsa(&handler_url).unwrap();
1794 let undef: Vec<_> = issues
1795 .iter()
1796 .filter(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. }))
1797 .collect();
1798 assert!(
1799 undef.is_empty(),
1800 "PSR-4 lazy-loading must prevent UndefinedClass for App\\Model\\Entity; got: {undef:?}"
1801 );
1802 }
1803
1804 #[test]
1819 fn all_docs_for_scan_does_not_reparse_indexed_files() {
1820 let store = DocumentStore::new();
1821 const N: usize = 50;
1822 for i in 0..N {
1823 let u = uri(&format!("/scan/file{i}.php"));
1824 store.ingest(u, &format!("<?php\nclass C{i} {{}}\nfunction f{i}() {{}}"));
1825 }
1826
1827 let first: Vec<_> = store.all_docs_for_scan();
1828 let second: Vec<_> = store.all_docs_for_scan();
1829 assert_eq!(first.len(), N);
1830 assert_eq!(second.len(), N);
1831
1832 let by_url_first: std::collections::HashMap<Url, Arc<ParsedDoc>> =
1833 first.into_iter().collect();
1834 for (u, doc2) in second {
1835 let doc1 = by_url_first
1836 .get(&u)
1837 .expect("second scan returned a URL the first didn't");
1838 assert!(
1839 Arc::ptr_eq(doc1, &doc2),
1840 "{u} re-parsed across all_docs_for_scan calls — \
1841 cache (parsed_cache + salsa parsed_doc memo) failed to hit"
1842 );
1843 }
1844
1845 let edited_url = uri("/scan/file0.php");
1848 let pre_edit = store.get_doc_salsa(&edited_url).unwrap();
1849 store.ingest(edited_url.clone(), "<?php\nclass C0Edited {}");
1850 let post_edit = store.get_doc_salsa(&edited_url).unwrap();
1851 assert!(
1852 !Arc::ptr_eq(&pre_edit, &post_edit),
1853 "edited file must produce a fresh ParsedDoc"
1854 );
1855 for i in 1..N {
1856 let u = uri(&format!("/scan/file{i}.php"));
1857 let original = by_url_first.get(&u).unwrap();
1858 let after = store.get_doc_salsa(&u).unwrap();
1859 assert!(
1860 Arc::ptr_eq(original, &after),
1861 "{u} should not have re-parsed because of an unrelated edit"
1862 );
1863 }
1864 }
1865
1866 #[test]
1871 fn body_only_edit_does_not_invalidate_sibling_analysis_cache() {
1872 let store = DocumentStore::new();
1873 let ua = uri("/ic_a.php");
1874 let ub = uri("/ic_b.php");
1875
1876 open(
1878 &store,
1879 ua.clone(),
1880 "<?php\nfunction a() { return 1; }".to_string(),
1881 );
1882 open(
1883 &store,
1884 ub.clone(),
1885 "<?php\nfunction b() { return 2; }".to_string(),
1886 );
1887 let _ = store.cached_analysis(&ua).unwrap();
1888 let analysis_b_first = store.cached_analysis(&ub).unwrap();
1889 let ver_after_warm = store.caches.decl_version();
1890
1891 store.mirror_text(&ua, "<?php\nfunction a() { return 999; }");
1893 let _ = store.cached_analysis(&ua);
1894 let ver_after_body_edit = store.caches.decl_version();
1895 assert_eq!(
1896 ver_after_warm, ver_after_body_edit,
1897 "body-only edit must not bump decl_version"
1898 );
1899
1900 let analysis_b_second = store.cached_analysis_if_fresh(&ub);
1902 assert!(
1903 analysis_b_second.is_some(),
1904 "B's analysis should hit cache after body-only edit to A"
1905 );
1906 assert!(
1907 Arc::ptr_eq(&analysis_b_first, &analysis_b_second.unwrap()),
1908 "B's analysis should be the identical Arc (no re-analysis)"
1909 );
1910
1911 store.mirror_text(&ua, "<?php\nfunction a_renamed() { return 999; }");
1913 let _ = store.cached_analysis(&ua);
1914 let ver_after_decl_edit = store.caches.decl_version();
1915 assert!(
1916 ver_after_decl_edit > ver_after_body_edit,
1917 "declaration edit must bump decl_version (was {ver_after_body_edit}, now {ver_after_decl_edit})"
1918 );
1919
1920 let analysis_b_stale = store.cached_analysis_if_fresh(&ub);
1922 assert!(
1923 analysis_b_stale.is_none(),
1924 "B's analysis should be stale after A's declaration changed"
1925 );
1926 }
1927
1928 #[test]
1932 fn snapshot_query_survives_concurrent_writes() {
1933 use std::sync::Arc;
1934 use std::thread;
1935 use std::time::{Duration, Instant};
1936
1937 let store = Arc::new(DocumentStore::new());
1938 let u = uri("/sq_test.php");
1939 open(
1940 &store,
1941 u.clone(),
1942 "<?php\nfunction f(): int { return 1; }".to_string(),
1943 );
1944
1945 let deadline = Instant::now() + Duration::from_millis(200);
1946 let mut handles = Vec::new();
1947
1948 {
1950 let store = Arc::clone(&store);
1951 let u = u.clone();
1952 handles.push(thread::spawn(move || {
1953 let mut rev = 0u32;
1954 while Instant::now() < deadline {
1955 store.mirror_text(&u, &format!("<?php\nfunction f(): int {{ return {rev}; }}"));
1956 rev += 1;
1957 }
1958 }));
1959 }
1960
1961 for _ in 0..4 {
1963 let store = Arc::clone(&store);
1964 let u = u.clone();
1965 handles.push(thread::spawn(move || {
1966 while Instant::now() < deadline {
1967 let _ = store.get_doc_salsa(&u);
1968 let _ = store.get_index_salsa(&u);
1969 }
1970 }));
1971 }
1972
1973 for h in handles {
1974 h.join()
1975 .expect("no panic in snapshot_query under concurrent writes");
1976 }
1977 }
1978
1979 #[test]
1983 fn owned_program_cache_reused_after_sibling_declaration_change() {
1984 let store = DocumentStore::new();
1985 let ua = uri("/prog_a.php");
1986 let ub = uri("/prog_b.php");
1987
1988 open(
1989 &store,
1990 ua.clone(),
1991 "<?php\nfunction alpha(): void {}".to_string(),
1992 );
1993 open(
1994 &store,
1995 ub.clone(),
1996 "<?php\nfunction beta(): void {}".to_string(),
1997 );
1998
1999 let _ = store.cached_analysis(&ua);
2001 let _ = store.cached_analysis(&ub);
2002
2003 let prog_b_first = store
2005 .caches
2006 .owned_program_cache
2007 .get(&ub)
2008 .map(|e| Arc::clone(&e.1))
2009 .expect("B's owned_program should be cached after first analysis");
2010
2011 store.mirror_text(&ua, "<?php\nfunction alpha_renamed(): void {}");
2013 let _ = store.cached_analysis(&ua);
2015
2016 let _ = store.cached_analysis(&ub);
2018
2019 let prog_b_second = store
2020 .caches
2021 .owned_program_cache
2022 .get(&ub)
2023 .map(|e| Arc::clone(&e.1))
2024 .expect("B's owned_program should still be cached after sibling edit");
2025
2026 assert!(
2027 Arc::ptr_eq(&prog_b_first, &prog_b_second),
2028 "B's owned_program Arc should be identical (cache hit) after sibling declaration change"
2029 );
2030 }
2031
2032 #[test]
2034 fn write_rev_increments_on_write() {
2035 let store = DocumentStore::new();
2036 let u = uri("/rev_test.php");
2037 let before = store.write_rev();
2038 store.mirror_text(&u, "<?php echo 1;");
2039 let after_first = store.write_rev();
2040 assert!(after_first > before, "first write must bump the revision");
2041 store.mirror_text(&u, "<?php echo 1;");
2043 assert_eq!(
2044 store.write_rev(),
2045 after_first,
2046 "identical text must not bump the revision"
2047 );
2048 store.mirror_text(&u, "<?php echo 2;");
2050 assert!(
2051 store.write_rev() > after_first,
2052 "changed text must bump the revision"
2053 );
2054 }
2055
2056 #[test]
2059 fn begin_reanalyze_cancels_previous_token() {
2060 let store = DocumentStore::new();
2061 let first = store.begin_reanalyze();
2062 assert!(!first.is_cancelled(), "a fresh token starts un-cancelled");
2063 let second = store.begin_reanalyze();
2064 assert!(
2065 first.is_cancelled(),
2066 "issuing a new token must cancel the previous in-flight sweep"
2067 );
2068 assert!(
2069 !second.is_cancelled(),
2070 "the newly-issued token must itself be un-cancelled"
2071 );
2072 }
2073
2074 #[test]
2076 fn write_rev_increments_on_remove() {
2077 let store = DocumentStore::new();
2078 let u = uri("/rev_remove.php");
2079 store.mirror_text(&u, "<?php class A {}");
2080 let before = store.write_rev();
2081 store.remove(&u);
2082 assert!(store.write_rev() > before, "remove must bump the revision");
2083 }
2084}