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 fn warm_analysis_sweep(&self, priority: &[Url], cancel: &mir_analyzer::IndexCancel) {
235 std::thread::scope(|s| {
240 let _ = std::thread::Builder::new()
241 .name("php-lsp-warm-sweep".into())
242 .stack_size(32 * 1024 * 1024)
243 .spawn_scoped(s, || self.warm_analysis_sweep_inner(priority, cancel))
244 .map(|h| h.join());
245 });
246 }
247
248 fn warm_analysis_sweep_inner(&self, priority: &[Url], cancel: &mir_analyzer::IndexCancel) {
249 const CHUNK: usize = 32;
250 let front: Vec<Arc<str>> = self.sweep_priority_files(priority);
251 let front_set: HashSet<&str> = front.iter().map(|f| f.as_ref()).collect();
252 let files: Vec<Arc<str>> = front
253 .iter()
254 .cloned()
255 .chain(
256 self.lsp_ws_files
257 .iter()
258 .filter(|e| !self.deleted_uris.contains(e.key()))
259 .map(|e| Arc::<str>::from(e.key().as_str()))
260 .filter(|f| !front_set.contains(f.as_ref())),
261 )
262 .collect();
263 drop(front_set);
264 let session = self.analysis_session(self.workspace_php_version());
265 let mut all_chunks_settled = true;
266 for chunk in files.chunks(CHUNK) {
267 if cancel.is_cancelled() {
268 return;
269 }
270 self.yield_to_interactive_reads();
271 loop {
284 if cancel.is_cancelled() {
285 all_chunks_settled = false;
286 break;
287 }
288 if salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
289 session.reanalyze_files_cancellable(chunk, cancel)
290 }))
291 .is_ok()
292 {
293 break;
294 }
295 }
296 }
297 if !cancel.is_cancelled() && all_chunks_settled {
298 session.flush_analysis_cache();
304 self.warm_sweeps_completed.fetch_add(1, Ordering::Relaxed);
305 }
306 }
307
308 pub fn flush_analysis_cache(&self) {
311 self.analysis_session(self.workspace_php_version())
312 .flush_analysis_cache();
313 }
314
315 pub fn warm_sweeps_completed(&self) -> u64 {
317 self.warm_sweeps_completed.load(Ordering::Relaxed)
318 }
319
320 fn sweep_priority_files(&self, priority: &[Url]) -> Vec<Arc<str>> {
326 if priority.is_empty() {
327 return Vec::new();
328 }
329 let ws = self.get_workspace_index_salsa();
330 let mut out: Vec<Arc<str>> = Vec::new();
331 let mut seen: HashSet<Arc<str>> = HashSet::new();
332 let push = |uri: &Url, out: &mut Vec<Arc<str>>, seen: &mut HashSet<Arc<str>>| {
333 let f: Arc<str> = Arc::from(uri.as_str());
334 if seen.insert(Arc::clone(&f)) {
335 out.push(f);
336 }
337 };
338 for uri in priority {
339 push(uri, &mut out, &mut seen);
340 }
341 for uri in priority {
342 let Some(doc) = self.get_doc_salsa(uri) else {
343 continue;
344 };
345 for fqn in crate::navigation::references::collect_referenced_class_fqns(&doc) {
346 let short = crate::text::fqn_short_name(&fqn);
347 let Some(refs) = ws.classes_by_name.get(short) else {
348 continue;
349 };
350 for &r in refs {
351 if let Some((decl_uri, cls)) = ws.at(r)
352 && cls.fqn.trim_start_matches('\\') == fqn
353 {
354 push(decl_uri, &mut out, &mut seen);
355 }
356 }
357 }
358 }
359 out
360 }
361
362 pub fn mark_index_ready(&self) {
365 self.index_ready.store(true, Ordering::Release);
366 }
367
368 pub fn is_index_ready(&self) -> bool {
370 self.index_ready.load(Ordering::Acquire)
371 }
372
373 pub fn write_rev(&self) -> u64 {
378 self.write_revision.load(Ordering::Acquire)
379 }
380
381 pub fn set_session_cache_dir(&self, dir: std::path::PathBuf) {
385 let _ = self.session_cache_dir.set(dir);
386 }
387
388 pub fn set_autoload_uris(&self, uris: Vec<Url>) {
394 *self.autoload_uris.write().unwrap() = uris;
395 self.caches.evict_analysis_all();
396 }
397
398 pub fn analysis_session(
406 &self,
407 php_version: mir_analyzer::PhpVersion,
408 ) -> Arc<mir_analyzer::AnalysisSession> {
409 let mut guard = self.analysis_session.lock().unwrap();
410 if let Some((cached_ver, session)) = guard.as_ref()
411 && *cached_ver == php_version
412 {
413 return Arc::clone(session);
414 }
415 let resolver: Arc<dyn mir_analyzer::ClassResolver> = self.psr4.load_full();
419 let mut builder =
423 mir_analyzer::AnalysisSession::new(php_version).with_class_resolver(resolver);
424 if let Some(dir) = self.session_cache_dir.get() {
425 builder = builder.with_cache_dir(dir);
426 }
427 let session = Arc::new(builder);
428 *guard = Some((php_version, Arc::clone(&session)));
429 session
430 }
431
432 pub fn workspace_php_version(&self) -> mir_analyzer::PhpVersion {
434 *self.php_version.lock().unwrap()
435 }
436
437 pub fn class_subtype_urls(&self, class_fqn: &str) -> Vec<tower_lsp::lsp_types::Url> {
445 let session = self.analysis_session(self.workspace_php_version());
446 session
447 .subtype_files(class_fqn)
448 .into_iter()
449 .filter_map(|p| tower_lsp::lsp_types::Url::parse(&p).ok())
450 .collect()
451 }
452
453 pub fn psr4_arc(&self) -> Arc<ArcSwap<Psr4Map>> {
458 Arc::clone(&self.psr4)
459 }
460
461 fn input_durability(uri: &Url) -> salsa::Durability {
464 if uri.as_str().contains("/vendor/") {
465 salsa::Durability::HIGH
466 } else {
467 salsa::Durability::LOW
468 }
469 }
470
471 fn lsp_ws_file(&self, uri: &Url) -> Option<LspWsFile> {
473 if self.deleted_uris.contains(uri) {
474 return None;
475 }
476 self.lsp_ws_files.get(uri).map(|e| *e)
477 }
478
479 fn snapshot_mir_query<R>(&self, f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R) -> R {
488 use std::panic::AssertUnwindSafe;
489 let _interactive = self.interactive_read_guard();
490 let session = self.analysis_session(self.workspace_php_version());
491 loop {
496 let db = session.snapshot_db();
497 match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
498 Ok(r) => return r,
499 Err(_) => drop(db),
500 }
501 }
502 }
503
504 fn try_snapshot_mir_query<R>(
510 &self,
511 attempts: usize,
512 f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R,
513 ) -> Option<R> {
514 use std::panic::AssertUnwindSafe;
515 let _interactive = self.interactive_read_guard();
516 let session = self.analysis_session(self.workspace_php_version());
517 for _ in 0..attempts {
518 let db = session.snapshot_db();
519 match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
520 Ok(r) => return Some(r),
521 Err(_) => drop(db),
522 }
523 }
524 None
525 }
526
527 pub fn mirror_text(&self, uri: &Url, text: &str) {
532 if let Some(cached) = self.caches.text_cache.get(uri)
538 && **cached == *text
539 && !self.deleted_uris.contains(uri)
540 && self.lsp_ws_files.contains_key(uri)
541 {
542 return;
543 }
544 self.mirror_text_arc(uri, Arc::from(text))
545 }
546
547 pub fn mirror_text_arc(&self, uri: &Url, text_arc: Arc<str>) {
554 let dur = Self::input_durability(uri);
555 let path: Arc<str> = Arc::from(uri.as_str());
556 let session = self.analysis_session(self.workspace_php_version());
557 if let Some(wf) = self.lsp_ws_files.get(uri).map(|e| *e) {
558 self.deleted_uris.remove(uri);
559 if let Some(cached) = self.caches.text_cache.get(uri)
562 && **cached == *text_arc
563 {
564 return;
565 }
566 session.with_db_mut(|db| {
567 let sf = wf.source(db);
568 sf.set_text(db).with_durability(dur).to(text_arc.clone());
569 if wf.cached_index(db).is_some() {
572 wf.set_cached_index(db).to(None);
573 }
574 });
575 self.caches.text_cache.insert(uri.clone(), text_arc);
576 self.caches.evict_analysis(uri);
579 self.write_revision.fetch_add(1, Ordering::Release);
580 } else {
581 let wf = session.with_db_mut(|db| {
582 let sf = db.upsert_source_file_with_durability(path, text_arc.clone(), dur);
583 LspWsFile::new(db, sf, None)
584 });
585 self.lsp_ws_files.insert(uri.clone(), wf);
586 self.caches.text_cache.insert(uri.clone(), text_arc);
587 self.workspace_files_dirty.store(true, Ordering::Release);
588 self.write_revision.fetch_add(1, Ordering::Release);
589 }
590 }
591
592 #[cfg(test)]
594 pub fn source_file(&self, uri: &Url) -> Option<LspWsFile> {
595 if self.deleted_uris.contains(uri) {
596 return None;
597 }
598 self.lsp_ws_files.get(uri).map(|e| *e)
599 }
600
601 pub fn seed_cached_index(&self, uri: &Url, index: Arc<FileIndex>) -> bool {
614 let Some(wf) = self.lsp_ws_file(uri) else {
615 return false;
616 };
617 let session = self.analysis_session(self.workspace_php_version());
618 session.with_db_mut(|db| wf.set_cached_index(db).to(Some(index)));
619 true
620 }
621
622 pub fn evict_token_cache(&self, uri: &Url) {
626 self.caches.evict_tokens(uri);
627 }
628
629 #[cfg(test)]
635 pub fn source_files_len(&self) -> usize {
636 self.lsp_ws_files.len()
637 }
638
639 #[cfg(test)]
640 pub fn snapshot_query_file_index(
641 &self,
642 uri: &Url,
643 ) -> Option<crate::index::file_index::FileIndex> {
644 let wf = self.lsp_ws_file(uri)?;
645 Some(
646 self.snapshot_mir_query(move |db| {
647 (*crate::db::mir_queries::file_index(db, wf).0).clone()
648 }),
649 )
650 }
651
652 pub fn ingest(&self, uri: Url, text: &str) {
657 self.mirror_text(&uri, text);
658 }
659
660 pub fn ingest_from_doc(&self, uri: Url, doc: &ParsedDoc) {
669 self.mirror_text_arc(&uri, doc.source_arc());
670 }
671
672 pub fn remove(&self, uri: &Url) {
673 self.caches.evict(uri);
674 self.deleted_uris.insert(uri.clone());
683 self.workspace_files_dirty.store(true, Ordering::Release);
684 self.sync_workspace_files();
687 let guard = self.analysis_session.lock().unwrap();
691 if let Some((_, session)) = guard.as_ref() {
692 session.invalidate_file(uri.as_str());
693 if let Some(wf) = self.lsp_ws_files.get(uri).map(|e| *e) {
701 session.with_db_mut(|db| {
702 if wf.cached_index(db).is_some() {
703 wf.set_cached_index(db).to(None);
704 }
705 });
706 let db = session.snapshot_db();
707 let _ = crate::db::mir_queries::file_index(&db, wf);
708 }
709 }
710 self.write_revision.fetch_add(1, Ordering::Release);
711 }
712
713 pub fn get_doc_salsa(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
726 self.get_parsed_cached(uri)
727 }
728
729 pub fn get_index_salsa(&self, uri: &Url) -> Option<Arc<FileIndex>> {
731 let wf = self.lsp_ws_file(uri)?;
732 Some(
733 self.snapshot_mir_query(move |db| crate::db::mir_queries::file_index(db, wf).0.clone()),
734 )
735 }
736
737 pub fn get_symbol_map_salsa(
740 &self,
741 uri: &Url,
742 ) -> Option<Arc<crate::types::symbol_map::SymbolMap>> {
743 let wf = self.lsp_ws_file(uri)?;
745 Some(self.snapshot_mir_query(move |db| {
746 let sf = *wf.source(db);
747 crate::db::mir_queries::symbol_map(db, sf).0.clone()
748 }))
749 }
750
751 pub fn other_symbol_maps(
753 &self,
754 uri: &Url,
755 open_urls: &[Url],
756 ) -> Vec<(Url, Arc<crate::types::symbol_map::SymbolMap>)> {
757 open_urls
758 .iter()
759 .filter(|u| *u != uri)
760 .filter_map(|u| self.get_symbol_map_salsa(u).map(|m| (u.clone(), m)))
761 .collect()
762 }
763
764 fn get_parsed_cached(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
771 if let Some(current_text) = self.caches.text_cache.get(uri)
772 && let Some(entry) = self.caches.parsed_cache.get(uri)
773 && Arc::ptr_eq(&*current_text, &entry.0)
774 {
775 let doc = entry.1.clone();
776 drop(entry);
777 self.caches.touch(uri);
778 return Some(doc);
779 }
780
781 let wf = self.lsp_ws_file(uri)?;
783 self.caches.bump_parse_count();
784 let (text, doc) = self.snapshot_mir_query(move |db| {
785 let sf = *wf.source(db);
786 let text = sf.text(db).clone();
787 let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
788 (text, doc)
789 });
790 self.caches.insert_parsed(uri.clone(), text, doc.clone());
791 Some(doc)
792 }
793
794 pub fn get_doc_snapshot_or_stale(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
802 if let Some(current_text) = self.caches.text_cache.get(uri)
803 && let Some(entry) = self.caches.parsed_cache.get(uri)
804 && Arc::ptr_eq(&*current_text, &entry.0)
805 {
806 let doc = entry.1.clone();
807 drop(entry);
808 self.caches.touch(uri);
809 return Some(doc);
810 }
811
812 if let Some(wf) = self.lsp_ws_file(uri)
813 && let Some((text, doc)) = self.try_snapshot_mir_query(3, move |db| {
814 let sf = *wf.source(db);
815 let text = sf.text(db).clone();
816 let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
817 (text, doc)
818 })
819 {
820 self.caches.bump_parse_count();
821 self.caches.insert_parsed(uri.clone(), text, doc.clone());
822 return Some(doc);
823 }
824
825 self.caches.parsed_cache.get(uri).map(|e| e.1.clone())
826 }
827
828 pub fn sync_workspace_files(&self) {
833 if !self.workspace_files_dirty.swap(false, Ordering::AcqRel) {
836 return;
837 }
838
839 let mut entries: Vec<(Arc<str>, LspWsFile)> = self
841 .lsp_ws_files
842 .iter()
843 .filter(|e| !self.deleted_uris.contains(e.key()))
844 .map(|e| (Arc::<str>::from(e.key().as_str()), *e.value()))
845 .collect();
846 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
847 let files: Arc<[LspWsFile]> = entries.iter().map(|(_, wf)| *wf).collect();
848
849 let session = self.analysis_session(self.workspace_php_version());
850 let mut guard = self.lsp_workspace.lock().unwrap();
851 session.with_db_mut(|db| match *guard {
852 Some(ws) => {
853 ws.set_files(db).to(files);
854 }
855 None => *guard = Some(LspWorkspace::new(db, files)),
856 });
857 }
858
859 pub fn mark_workspace_files_dirty(&self) {
863 self.workspace_files_dirty.store(true, Ordering::Release);
864 }
865
866 pub fn set_php_version(&self, version: mir_analyzer::PhpVersion) {
871 {
872 let mut guard = self.php_version.lock().unwrap();
873 if *guard == version {
874 return;
875 }
876 *guard = version;
877 }
878 self.lsp_ws_files.clear();
883 *self.lsp_workspace.lock().unwrap() = None;
884 self.workspace_files_dirty.store(true, Ordering::Release);
885 self.caches.evict_analysis_all();
887 }
888
889 pub fn indexed_references(
902 &self,
903 symbol: &mir_analyzer::Name,
904 files: &[Arc<str>],
905 include_declaration: bool,
906 cancel_rev: Option<u64>,
907 ) -> Vec<(Arc<str>, u32, u32, u32)> {
908 let php_version = self.workspace_php_version();
909 let stale =
915 || cancel_rev.is_some_and(|rev| self.write_revision.load(Ordering::Acquire) != rev);
916 let raw = loop {
920 let session = self.analysis_session(php_version);
921 match salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
922 session.indexed_references_to(symbol, files, include_declaration, &stale)
923 })) {
924 Ok(Some(refs)) => break refs,
925 Ok(None) => {
930 std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
931 }
932 Err(_) if stale() => {
933 std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
934 }
935 Err(_) => {}
939 }
940 };
941 raw.into_iter()
942 .map(|(file, range)| {
943 let line = range.start.line.saturating_sub(1);
945 let col_start = range.start.column;
946 let col_end = range.end.column;
947 (file, line, col_start, col_end)
948 })
949 .collect()
950 }
951
952 pub fn indexed_use_imports(
958 &self,
959 symbol: &mir_analyzer::Name,
960 files: &[Arc<str>],
961 ) -> Vec<(Arc<str>, u32, u32, u32)> {
962 let session = self.analysis_session(self.workspace_php_version());
963 let _interactive = self.interactive_read_guard();
964 let raw = loop {
965 if let Ok(locs) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
966 session.indexed_use_import_locations(symbol, files)
967 })) {
968 break locs;
969 }
970 };
971 raw.into_iter()
972 .map(|(file, range)| {
973 let line = range.start.line.saturating_sub(1);
975 (file, line, range.start.column, range.end.column)
976 })
977 .collect()
978 }
979
980 pub fn warm_start_indexes(&self) {
986 let files: Vec<(Arc<str>, Arc<str>)> = self
987 .lsp_ws_files
988 .iter()
989 .filter(|e| !self.deleted_uris.contains(e.key()))
990 .filter_map(|e| {
991 let text = self.caches.text_cache.get(e.key())?;
992 Some((Arc::<str>::from(e.key().as_str()), Arc::clone(&text)))
993 })
994 .collect();
995 if files.is_empty() {
996 return;
997 }
998 let session = self.analysis_session(self.workspace_php_version());
999 session.warm_start_files(&files);
1000 }
1001
1002 pub(crate) fn reference_candidate_files(&self, symbol: &mir_analyzer::Name) -> Vec<Arc<str>> {
1009 if let mir_analyzer::Name::Method { class, name } = symbol
1010 && let Some(urls) = self.method_reference_scope(class, name)
1011 {
1012 return urls.iter().map(|u| Arc::from(u.as_str())).collect();
1013 }
1014 self.workspace_file_paths()
1015 }
1016
1017 pub fn workspace_file_paths(&self) -> Vec<Arc<str>> {
1021 self.lsp_ws_files
1022 .iter()
1023 .filter(|e| !self.deleted_uris.contains(e.key()))
1024 .map(|e| Arc::<str>::from(e.key().as_str()))
1025 .collect()
1026 }
1027
1028 pub fn indexed_subtype_classes(
1033 &self,
1034 class_fqn: &str,
1035 include_trait_users: bool,
1036 ) -> Vec<mir_analyzer::SubtypeClassSite> {
1037 let files = self.workspace_file_paths();
1038 let session = self.analysis_session(self.workspace_php_version());
1039 let _interactive = self.interactive_read_guard();
1040 loop {
1041 if let Ok(sites) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1042 session.indexed_subtype_classes(class_fqn, &files, include_trait_users)
1043 })) {
1044 break sites;
1045 }
1046 }
1047 }
1048
1049 pub fn indexed_method_implementations(
1052 &self,
1053 class_fqn: &str,
1054 method: &str,
1055 ) -> Vec<(Arc<str>, Arc<str>, mir_analyzer::Range)> {
1056 let files = self.workspace_file_paths();
1057 let session = self.analysis_session(self.workspace_php_version());
1058 let _interactive = self.interactive_read_guard();
1059 loop {
1060 if let Ok(sites) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1061 session.indexed_method_implementations(class_fqn, method, &files)
1062 })) {
1063 break sites;
1064 }
1065 }
1066 }
1067
1068 pub fn method_reference_scope(&self, owner_fqn: &str, method: &str) -> Option<Vec<Url>> {
1092 use crate::index::file_index::{ClassKind, Visibility};
1093
1094 let ws = self.get_workspace_index_salsa();
1095 let owner_fqn = owner_fqn.trim_start_matches('\\');
1096 let owner_short = crate::text::fqn_short_name(owner_fqn);
1097
1098 let (decl_uri, decl_class) = ws
1099 .classes_by_name
1100 .get(owner_short)?
1101 .iter()
1102 .filter_map(|&r| ws.at(r))
1103 .find(|(_, cls)| cls.fqn.trim_start_matches('\\') == owner_fqn)?;
1104
1105 if !matches!(decl_class.kind, ClassKind::Class | ClassKind::Enum)
1108 || !decl_class.traits.is_empty()
1109 || !decl_class.mixins.is_empty()
1110 {
1111 return None;
1112 }
1113
1114 let vis = decl_class
1115 .methods
1116 .iter()
1117 .find(|m| m.name.eq_ignore_ascii_case(method))?
1118 .visibility;
1119
1120 match vis {
1121 Visibility::Private => Some(vec![decl_uri.clone()]),
1122 Visibility::Protected => {
1123 if !self.is_index_ready() {
1124 return None;
1125 }
1126 let session = self.analysis_session(self.workspace_php_version());
1131 let mut files: std::collections::HashSet<Url> = session
1132 .subtype_files(owner_fqn)
1133 .into_iter()
1134 .filter_map(|p| Url::parse(&p).ok())
1135 .collect();
1136 files.insert(decl_uri.clone());
1137 Some(files.into_iter().collect())
1138 }
1139 Visibility::Public => None,
1140 }
1141 }
1142
1143 pub fn get_workspace_index_salsa(&self) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
1153 self.sync_workspace_files();
1154 let ws = *self.lsp_workspace.lock().unwrap();
1155 let Some(ws) = ws else {
1156 return Arc::new(crate::db::workspace_index::WorkspaceIndexData {
1157 files: Vec::new(),
1158 classes_by_name: std::collections::HashMap::new(),
1159 subtypes_of: std::collections::HashMap::new(),
1160 decls_by_name: std::collections::HashMap::new(),
1161 classes_by_lowercase_name: Vec::new(),
1162 });
1163 };
1164 self.snapshot_mir_query(move |db| crate::db::mir_queries::workspace_index(db, ws).0.clone())
1165 }
1166
1167 pub fn parse_count(&self) -> u64 {
1171 self.caches.parse_count()
1172 }
1173
1174 pub fn ref_index_lock_count(&self) -> u64 {
1179 self.analysis_session(self.workspace_php_version())
1180 .ref_index_lock_count()
1181 }
1182
1183 pub fn source_text(&self, uri: &Url) -> Option<Arc<str>> {
1187 self.caches.text_cache.get(uri).map(|e| Arc::clone(&e))
1188 }
1189
1190 pub fn store_token_cache(&self, uri: &Url, result_id: String, tokens: Arc<Vec<SemanticToken>>) {
1193 self.caches.store_token(uri, result_id, tokens);
1194 }
1195
1196 pub fn get_token_cache(&self, uri: &Url, result_id: &str) -> Option<Arc<Vec<SemanticToken>>> {
1198 self.caches.get_token(uri, result_id)
1199 }
1200
1201 #[tracing::instrument(skip_all)]
1207 pub fn get_semantic_issues_salsa(&self, uri: &Url) -> Option<Arc<[mir_issues::Issue]>> {
1208 let analysis = self.cached_analysis(uri)?;
1209 let file: Arc<str> = Arc::from(uri.as_str());
1210 let class_issues = {
1216 let _s = tracing::debug_span!("session.class_issues_for").entered();
1217 loop {
1219 let session = self.analysis_session(self.workspace_php_version());
1220 if let Ok(issues) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1221 session.class_issues(std::slice::from_ref(&file))
1222 })) {
1223 break issues;
1224 }
1225 }
1226 };
1227 let combined: Vec<mir_issues::Issue> = analysis
1228 .issues
1229 .iter()
1230 .cloned()
1231 .chain(class_issues)
1232 .filter(|i| !i.suppressed)
1233 .collect();
1234 Some(Arc::from(combined))
1235 }
1236
1237 pub fn cached_analysis_if_fresh(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1251 let doc = self.get_doc_salsa(uri)?;
1252 let source = doc.source_arc();
1253 let cur_ver = self.caches.decl_version();
1254 let analysis = {
1255 let entry = self.caches.analysis_cache.get(uri)?;
1256 (Arc::ptr_eq(&entry.0, &source) && entry.1 == cur_ver).then(|| Arc::clone(&entry.2))?
1257 };
1258 self.caches.touch(uri);
1259 Some(analysis)
1260 }
1261
1262 pub fn cached_analysis(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1263 self.cached_analysis_cancellable(uri, &|| false)
1264 }
1265
1266 #[tracing::instrument(skip_all)]
1272 pub fn cached_analysis_cancellable(
1273 &self,
1274 uri: &Url,
1275 should_cancel: &(dyn Fn() -> bool + Sync),
1276 ) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1277 let doc = self.get_doc_salsa(uri)?;
1279 let source = doc.source_arc();
1280
1281 let cur_ver = self.caches.decl_version();
1282 if let Some(entry) = self.caches.analysis_cache.get(uri)
1283 && Arc::ptr_eq(&entry.0, &source)
1284 && entry.1 == cur_ver
1285 {
1286 let analysis = Arc::clone(&entry.2);
1287 drop(entry);
1288 self.caches.touch(uri);
1289 return Some(analysis);
1290 }
1291
1292 let php_version = self.workspace_php_version();
1293 let session = self.analysis_session(php_version);
1294 let file: Arc<str> = Arc::from(uri.as_str());
1295
1296 let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
1297 let owned_program = if let Some(cached) = self.caches.owned_program_cache.get(uri)
1298 && Arc::ptr_eq(&cached.0, &source)
1299 {
1300 Arc::clone(&cached.1)
1301 } else {
1302 let prog = Arc::new(php_ast::owned::to_owned_program(doc.program()));
1303 self.caches.shed_stale(
1304 &self.caches.owned_program_cache,
1305 crate::document::cache_registry::OWNED_PROGRAM_CACHE_CAP,
1306 );
1307 self.caches
1308 .owned_program_cache
1309 .insert(uri.clone(), (Arc::clone(&source), Arc::clone(&prog)));
1310 prog
1311 };
1312
1313 let autoload_texts: Vec<(Arc<str>, Arc<str>)> = {
1315 let autoload_uris = self.autoload_uris.read().unwrap().clone();
1316 autoload_uris
1317 .iter()
1318 .filter_map(|auri| {
1319 self.caches
1320 .text_cache
1321 .get(auri)
1322 .map(|t| (Arc::from(auri.as_str()), Arc::clone(&*t)))
1323 })
1324 .collect()
1325 };
1326 let class_fqns = crate::references::collect_referenced_class_fqns(&doc);
1328
1329 let _interactive = self.interactive_read_guard();
1331 let analysis = loop {
1332 let attempt = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1333 session.ingest_file(file.clone(), source.clone());
1334 for (afile, atext) in &autoload_texts {
1335 session.ingest_file(afile.clone(), atext.clone());
1336 }
1337 for fqcn in &class_fqns {
1338 let _ = session.load_class(fqcn);
1339 }
1340 let analyzer = mir_analyzer::FileAnalyzer::new(&session);
1341 analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map)
1342 }));
1343 match attempt {
1344 Ok(a) => break Arc::new(a),
1345 Err(_) => {
1346 let text_changed = self
1352 .caches
1353 .text_cache
1354 .get(uri)
1355 .is_none_or(|t| !Arc::ptr_eq(&t, &source));
1356 if text_changed || should_cancel() {
1357 return None;
1358 }
1359 }
1360 }
1361 };
1362 let new_index = self.get_index_salsa(uri);
1368 let old_fp = self
1369 .caches
1370 .decl_fingerprints
1371 .get(uri)
1372 .map(|e| Arc::clone(&*e));
1373 let decl_changed = match (&old_fp, &new_index) {
1374 (Some(old), Some(new)) => **old != **new,
1375 (None, Some(new)) => !new.declares_nothing(),
1379 _ => false,
1380 };
1381 if decl_changed {
1382 if let Some(idx) = new_index {
1383 self.caches.decl_fingerprints.insert(uri.clone(), idx);
1384 }
1385 self.caches.bump_decl_version();
1386 session.bump_prepare_generation();
1391 }
1392 let ver = cur_ver + u64::from(decl_changed);
1398 self.caches.shed_stale(
1399 &self.caches.analysis_cache,
1400 crate::document::cache_registry::ANALYSIS_CACHE_CAP,
1401 );
1402 self.caches.touch(uri);
1403 self.caches
1404 .analysis_cache
1405 .insert(uri.clone(), (source, ver, Arc::clone(&analysis)));
1406 Some(analysis)
1407 }
1408
1409 pub fn docs_for(&self, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
1414 open_urls
1415 .iter()
1416 .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
1417 .collect()
1418 }
1419
1420 pub fn doc_with_others(
1423 &self,
1424 uri: &Url,
1425 doc: Arc<ParsedDoc>,
1426 open_urls: &[Url],
1427 ) -> Vec<(Url, Arc<ParsedDoc>)> {
1428 let mut result = vec![(uri.clone(), doc)];
1429 result.extend(self.other_docs(uri, open_urls));
1430 result
1431 }
1432
1433 pub fn other_docs(&self, uri: &Url, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
1435 open_urls
1436 .iter()
1437 .filter(|u| *u != uri)
1438 .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
1439 .collect()
1440 }
1441
1442 pub fn all_indexes(&self) -> Vec<(Url, Arc<FileIndex>)> {
1444 self.get_workspace_index_salsa().files.clone()
1445 }
1446
1447 pub fn cache_vendor_index(&self, uri: Url, index: Arc<FileIndex>) {
1451 self.caches.vendor_index_cache.insert(uri, index);
1452 }
1453
1454 pub fn get_vendor_index(&self, uri: &Url) -> Option<Arc<FileIndex>> {
1456 self.caches
1457 .vendor_index_cache
1458 .get(uri)
1459 .map(|e| Arc::clone(&*e))
1460 }
1461
1462 pub fn other_indexes(&self, uri: &Url) -> Vec<(Url, Arc<FileIndex>)> {
1464 self.get_workspace_index_salsa()
1465 .files
1466 .iter()
1467 .filter(|(u, _)| u != uri)
1468 .cloned()
1469 .collect()
1470 }
1471
1472 pub fn all_docs_for_scan(&self) -> Vec<(Url, Arc<ParsedDoc>)> {
1476 let urls: Vec<Url> = self
1477 .lsp_ws_files
1478 .iter()
1479 .filter(|e| !self.deleted_uris.contains(e.key()))
1480 .map(|e| e.key().clone())
1481 .collect();
1482 urls.into_iter()
1483 .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
1484 .collect()
1485 }
1486
1487 pub fn files_importing(&self, fqn: &str) -> Vec<Url> {
1492 let target = fqn.trim_start_matches('\\');
1493 self.get_workspace_index_salsa()
1494 .files
1495 .iter()
1496 .filter(|(_, idx)| {
1497 idx.use_imports
1498 .iter()
1499 .any(|(_, f)| f.trim_start_matches('\\').eq_ignore_ascii_case(target))
1500 })
1501 .map(|(u, _)| u.clone())
1502 .collect()
1503 }
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508 use super::*;
1509
1510 fn uri(path: &str) -> Url {
1511 Url::parse(&format!("file://{path}")).unwrap()
1512 }
1513
1514 fn open(store: &DocumentStore, u: Url, text: String) {
1519 store.mirror_text(&u, &text);
1520 }
1521
1522 fn spawn_yield_waiter(
1534 store: &Arc<DocumentStore>,
1535 ) -> (
1536 std::sync::mpsc::Receiver<()>,
1537 std::thread::JoinHandle<std::time::Duration>,
1538 ) {
1539 let (ready_tx, ready_rx) = std::sync::mpsc::channel();
1540 let store = Arc::clone(store);
1541 let handle = std::thread::spawn(move || {
1542 ready_tx.send(()).unwrap();
1543 let t = std::time::Instant::now();
1544 store.yield_to_interactive_reads();
1545 t.elapsed()
1546 });
1547 (ready_rx, handle)
1548 }
1549
1550 #[test]
1551 fn scan_writers_yield_while_interactive_reads_are_in_flight() {
1552 let store = Arc::new(DocumentStore::new());
1553
1554 let t = std::time::Instant::now();
1556 store.yield_to_interactive_reads();
1557 assert!(t.elapsed() < std::time::Duration::from_millis(200));
1558
1559 let guard = store.interactive_read_guard();
1564 let (ready_rx, waiter) = spawn_yield_waiter(&store);
1565 ready_rx.recv().unwrap();
1566 const HOLD: std::time::Duration = std::time::Duration::from_millis(60);
1567 std::thread::sleep(HOLD);
1568 drop(guard);
1569 let waited = waiter.join().unwrap();
1570 assert!(
1571 waited >= HOLD,
1572 "writer should have paused at least as long as the read guard was held (waited {waited:?})"
1573 );
1574
1575 let g1 = store.interactive_read_guard();
1577 let g2 = store.interactive_read_guard();
1578 drop(g1);
1579 let (ready_rx, waiter) = spawn_yield_waiter(&store);
1580 ready_rx.recv().unwrap();
1581 const HOLD2: std::time::Duration = std::time::Duration::from_millis(30);
1582 std::thread::sleep(HOLD2);
1583 drop(g2);
1584 let waited = waiter.join().unwrap();
1585 assert!(
1586 waited >= HOLD2,
1587 "writer should have paused while the nested read guard was held (waited {waited:?})"
1588 );
1589 store.yield_to_interactive_reads(); }
1591
1592 #[test]
1593 fn index_registers_file_in_salsa() {
1594 let store = DocumentStore::new();
1595 store.ingest(uri("/lib.php"), "<?php\nfunction lib_fn() {}");
1596 let idx = store.get_index_salsa(&uri("/lib.php")).unwrap();
1597 assert_eq!(idx.functions.len(), 1);
1598 assert_eq!(&*idx.functions[0].name, "lib_fn");
1599 }
1600
1601 #[test]
1602 fn remove_hides_file_from_index() {
1603 let store = DocumentStore::new();
1604 let u = uri("/lib.php");
1605 store.ingest(u.clone(), "<?php");
1606 store.remove(&u);
1607 assert!(store.get_index_salsa(&u).is_none());
1608 }
1609
1610 #[test]
1611 fn remove_frees_mir_source_text() {
1612 let store = DocumentStore::new();
1613 let u = uri("/lib.php");
1614 let big_text = format!("<?php\n{}", "// pad\n".repeat(1000));
1615 store.ingest(u.clone(), &big_text);
1616
1617 let session = store.analysis_session(store.workspace_php_version());
1618 let sf = session.lookup_source_file(u.as_str()).unwrap();
1619 let len_before = store.snapshot_mir_query(|db| sf.text(db).len());
1620 assert!(len_before > 1000, "sanity: source text should be mirrored");
1621
1622 store.remove(&u);
1623
1624 let len_after = store.snapshot_mir_query(|db| sf.text(db).len());
1625 assert_eq!(
1626 len_after, 0,
1627 "remove() must free mir's SourceFile text, not just hide the file from indexes"
1628 );
1629 }
1630
1631 #[test]
1632 fn remove_shrinks_file_index_memo() {
1633 let store = DocumentStore::new();
1634 let u = uri("/lib.php");
1635 store.ingest(u.clone(), "<?php\nclass BigClassBeforeDelete {}\n");
1636
1637 let wf = store.lsp_ws_files.get(&u).map(|e| *e).unwrap();
1638 let idx_before =
1639 store.snapshot_mir_query(|db| crate::db::mir_queries::file_index(db, wf).0.clone());
1640 assert_eq!(
1641 idx_before.classes.len(),
1642 1,
1643 "sanity: class should be indexed"
1644 );
1645
1646 store.remove(&u);
1647
1648 let idx_after =
1649 store.snapshot_mir_query(|db| crate::db::mir_queries::file_index(db, wf).0.clone());
1650 assert!(
1651 idx_after.classes.is_empty(),
1652 "remove() must shrink file_index's memo (no LRU cap, unlike parsed_doc/symbol_map) \
1653 against the emptied text, not hold the pre-deletion FileIndex forever"
1654 );
1655 }
1656
1657 #[test]
1658 fn remove_and_reopen_reuses_source_file_handle() {
1659 let store = DocumentStore::new();
1660 let u = uri("/lib.php");
1661 store.ingest(u.clone(), "<?php");
1662 let ft_before = store.source_file(&u).unwrap();
1663 store.remove(&u);
1664 assert!(
1665 store.source_file(&u).is_none(),
1666 "deleted file should be hidden"
1667 );
1668 store.mirror_text(&u, "<?php");
1669 let ft_after = store.source_file(&u).unwrap();
1670 assert!(
1671 ft_before == ft_after,
1672 "reopen must reuse the same FileText handle"
1673 );
1674 }
1675
1676 #[test]
1677 fn delete_reopen_churn_does_not_amplify_salsa_inputs() {
1678 let store = DocumentStore::new();
1679 let uris: Vec<Url> = (0..20).map(|i| uri(&format!("/churn/f{i}.php"))).collect();
1680 for u in &uris {
1681 store.ingest(u.clone(), "<?php class A {}");
1682 }
1683 let count_before = store.source_files_len();
1684 for _ in 0..10 {
1685 for u in &uris {
1686 store.remove(u);
1687 }
1688 for u in &uris {
1689 store.ingest(u.clone(), "<?php class A {}");
1690 }
1691 }
1692 assert_eq!(
1693 store.source_files_len(),
1694 count_before,
1695 "delete-reopen cycles must not create new salsa inputs (L1-B regression guard)"
1696 );
1697 }
1698
1699 #[test]
1700 fn warm_analysis_sweep_preserves_reference_results() {
1701 let store = DocumentStore::new();
1702 store.ingest(
1703 uri("/svc.php"),
1704 "<?php\nnamespace App;\nclass Svc { public function run(): void {} }",
1705 );
1706 store.ingest(
1707 uri("/caller.php"),
1708 "<?php\nnamespace App;\nclass Caller {\n private Svc $s;\n public function go(): void { $this->s->run(); }\n}",
1709 );
1710 let sym = mir_analyzer::Name::method("App\\Svc", "run");
1711 let files: Vec<Arc<str>> = [uri("/svc.php"), uri("/caller.php")]
1712 .iter()
1713 .map(|u| Arc::from(u.as_str()))
1714 .collect();
1715 let cold = store.indexed_references(&sym, &files, false, None);
1716 assert_eq!(cold.len(), 1, "caller.php references Svc::run once");
1717
1718 let cancel = store.begin_warm_sweep();
1719 store.warm_analysis_sweep(&[], &cancel);
1720 let warm = store.indexed_references(&sym, &files, false, None);
1721 assert_eq!(cold, warm, "sweep must not change reference results");
1722
1723 store.ingest(
1725 uri("/caller.php"),
1726 "<?php\nnamespace App;\nclass Caller {\n private Svc $s;\n public function go(): void { $this->s->run(); $this->s->run(); }\n}",
1727 );
1728 let cancel = store.begin_warm_sweep();
1729 store.warm_analysis_sweep(&[], &cancel);
1730 let after_edit = store.indexed_references(&sym, &files, false, None);
1731 assert_eq!(after_edit.len(), 2, "re-sweep must see the new reference");
1732 }
1733
1734 #[test]
1735 fn sweep_priority_puts_open_files_and_their_dependencies_first() {
1736 let store = DocumentStore::new();
1737 store.ingest(
1738 uri("/a.php"),
1739 "<?php\nnamespace App;\nclass A { public function go(B $b): void {} }",
1740 );
1741 store.ingest(uri("/b.php"), "<?php\nnamespace App;\nclass B {}");
1742 store.ingest(uri("/unrelated.php"), "<?php\nnamespace App;\nclass C {}");
1743 store.mark_index_ready();
1744
1745 let front = store.sweep_priority_files(&[uri("/a.php")]);
1746 let front: Vec<&str> = front.iter().map(|f| f.as_ref()).collect();
1747 assert_eq!(
1748 front,
1749 vec![uri("/a.php").as_str(), uri("/b.php").as_str()],
1750 "open file first, then the files declaring its referenced classes"
1751 );
1752 }
1753
1754 #[test]
1755 fn warm_analysis_sweep_stops_on_cancel() {
1756 let store = DocumentStore::new();
1757 for i in 0..40 {
1758 store.ingest(
1759 uri(&format!("/f{i}.php")),
1760 "<?php\nclass A { public function m(): int { return 1; } }",
1761 );
1762 }
1763 let cancel = store.begin_warm_sweep();
1764 cancel.cancel();
1765 store.warm_analysis_sweep(&[], &cancel);
1767 let old = store.begin_warm_sweep();
1769 let _new = store.begin_warm_sweep();
1770 assert!(old.is_cancelled());
1771 }
1772
1773 #[test]
1774 fn all_indexes_includes_every_mirrored_file() {
1775 let store = DocumentStore::new();
1776 open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1777 store.ingest(uri("/b.php"), "<?php\nfunction b() {}");
1778 assert_eq!(store.all_indexes().len(), 2);
1779 }
1780
1781 #[test]
1782 fn other_indexes_excludes_current_uri() {
1783 let store = DocumentStore::new();
1784 open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1785 open(&store, uri("/b.php"), "<?php\nfunction b() {}".to_string());
1786 assert_eq!(store.other_indexes(&uri("/a.php")).len(), 1);
1787 }
1788
1789 #[test]
1790 fn other_docs_excludes_current_uri() {
1791 let store = DocumentStore::new();
1792 let ua = uri("/a.php");
1793 let ub = uri("/b.php");
1794 open(&store, ua.clone(), "<?php\nfunction a() {}".to_string());
1795 open(&store, ub.clone(), "<?php\nfunction b() {}".to_string());
1796 let open_urls = vec![ua.clone(), ub];
1797 assert_eq!(store.other_docs(&ua, &open_urls).len(), 1);
1798 }
1799
1800 #[test]
1801 fn evict_token_cache_removes_entry() {
1802 let store = DocumentStore::new();
1803 let u = uri("/a.php");
1804 open(&store, u.clone(), "<?php".to_string());
1805 store.store_token_cache(&u, "id1".to_string(), Arc::new(vec![]));
1806 assert!(store.get_token_cache(&u, "id1").is_some());
1807 store.evict_token_cache(&u);
1808 assert!(store.get_token_cache(&u, "id1").is_none());
1809 }
1810
1811 #[test]
1812 fn vendor_index_cache_evicted_on_remove() {
1813 let store = DocumentStore::new();
1814 let u = uri("/vendor/acme/lib.php");
1815 store.ingest(u.clone(), "<?php\nclass Lib {}");
1816 let idx = store.get_index_salsa(&u).unwrap();
1817 store.cache_vendor_index(u.clone(), idx.clone());
1818 assert!(store.get_vendor_index(&u).is_some());
1819 store.remove(&u);
1820 assert!(store.get_vendor_index(&u).is_none());
1821 }
1822
1823 #[test]
1824 fn index_populates_file_index_with_symbols() {
1825 let store = DocumentStore::new();
1826 store.ingest(uri("/a.php"), "<?php\nfunction hello() {}");
1827 let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1828 assert_eq!(idx.functions.len(), 1);
1829 assert_eq!(&*idx.functions[0].name, "hello");
1830 }
1831
1832 #[test]
1833 fn open_populates_file_index_with_symbols() {
1834 let store = DocumentStore::new();
1835 open(&store, uri("/a.php"), "<?php\nclass Foo {}".to_string());
1836 let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1837 assert_eq!(idx.classes.len(), 1);
1838 assert_eq!(&*idx.classes[0].name, "Foo");
1839 }
1840
1841 fn names_of(idx: &FileIndex) -> Vec<String> {
1848 let mut out: Vec<String> = idx.classes.iter().map(|c| c.name.to_string()).collect();
1849 out.extend(idx.functions.iter().map(|f| f.name.to_string()));
1850 out.sort();
1851 out
1852 }
1853
1854 fn salsa_index_names(store: &DocumentStore, url: &Url) -> Vec<String> {
1855 store
1856 .snapshot_query_file_index(url)
1857 .map(|idx| names_of(&idx))
1858 .unwrap_or_default()
1859 }
1860
1861 #[test]
1862 fn mirror_tracks_repeated_edits() {
1863 let store = DocumentStore::new();
1864 let u = uri("/mirror.php");
1865
1866 open(&store, u.clone(), "<?php\nclass A {}".to_string());
1867 assert_eq!(salsa_index_names(&store, &u), vec!["A".to_string()]);
1868
1869 open(
1870 &store,
1871 u.clone(),
1872 "<?php\nclass A {}\nclass B {}".to_string(),
1873 );
1874 assert_eq!(
1875 salsa_index_names(&store, &u),
1876 vec!["A".to_string(), "B".to_string()]
1877 );
1878
1879 open(&store, u.clone(), "<?php\nfunction greet() {}".to_string());
1880 assert_eq!(salsa_index_names(&store, &u), vec!["greet".to_string()]);
1881 }
1882
1883 #[test]
1884 fn mirror_tracks_ingest_and_ingest_from_doc() {
1885 let store = DocumentStore::new();
1886
1887 let u1 = uri("/bg1.php");
1889 store.ingest(u1.clone(), "<?php\nclass Bg1 {}");
1890 assert_eq!(salsa_index_names(&store, &u1), vec!["Bg1".to_string()]);
1891
1892 let u2 = uri("/bg2.php");
1894 let doc = crate::analysis::diagnostics::parse_document_no_diags(
1895 "<?php\nclass Bg2 {}\nfunction f() {}",
1896 );
1897 store.ingest_from_doc(u2.clone(), &doc);
1898 assert_eq!(
1899 salsa_index_names(&store, &u2),
1900 vec!["Bg2".to_string(), "f".to_string()]
1901 );
1902 }
1903
1904 #[test]
1915 fn seed_cached_index_noops_for_unknown_uri() {
1916 let store = DocumentStore::new();
1917 let u = uri("/never_mirrored.php");
1918 let index = Arc::new(crate::index::file_index::FileIndex::default());
1919 assert!(!store.seed_cached_index(&u, index));
1920 }
1921
1922 #[test]
1924 fn parsed_cache_stays_bounded_under_many_inserts() {
1925 let store = DocumentStore::new();
1926 use crate::document::cache_registry::PARSED_CACHE_CAP;
1927 let overflow = PARSED_CACHE_CAP + 100;
1928 for i in 0..overflow {
1929 let u = uri(&format!("/cap/file{i}.php"));
1930 store.ingest(u.clone(), "<?php\nclass A {}");
1931 let _ = store.get_doc_salsa(&u);
1933 }
1934 assert!(
1935 store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1936 "parsed_cache grew to {} entries (cap {})",
1937 store.caches.parsed_cache.len(),
1938 PARSED_CACHE_CAP
1939 );
1940 }
1941
1942 #[test]
1946 fn parsed_cache_shed_keeps_recently_used_entries() {
1947 let store = DocumentStore::new();
1948 use crate::document::cache_registry::PARSED_CACHE_CAP;
1949
1950 let hot = uri("/cap/hot.php");
1951 store.ingest(hot.clone(), "<?php\nclass Hot {}");
1952 let _ = store.get_doc_salsa(&hot);
1953
1954 for i in 0..(PARSED_CACHE_CAP - 2) {
1957 let u = uri(&format!("/cap/cold{i}.php"));
1958 store.ingest(u.clone(), "<?php\nclass A {}");
1959 let _ = store.get_doc_salsa(&u);
1960 }
1961 let _ = store.get_doc_salsa(&hot);
1962
1963 let trigger = uri("/cap/trigger.php");
1965 store.ingest(trigger.clone(), "<?php\nclass T {}");
1966 let _ = store.get_doc_salsa(&trigger);
1967
1968 assert!(
1969 store.caches.parsed_cache.contains_key(&hot),
1970 "recently-touched entry must survive the recency shed"
1971 );
1972 assert!(
1973 store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1974 "cache must stay bounded after the shed"
1975 );
1976 }
1977
1978 #[test]
1982 fn analysis_cache_stays_bounded_under_many_files() {
1983 use crate::document::cache_registry::ANALYSIS_CACHE_CAP;
1984 let store = DocumentStore::new();
1985 let overflow = ANALYSIS_CACHE_CAP + 16;
1986 for i in 0..overflow {
1987 let u = uri(&format!("/acap/file{i}.php"));
1988 store.ingest(u.clone(), "<?php\nfunction f() { return 1; }");
1989 let _ = store.cached_analysis(&u);
1990 }
1991 assert!(
1992 store.caches.analysis_cache.len() <= ANALYSIS_CACHE_CAP,
1993 "analysis_cache grew to {} entries (cap {})",
1994 store.caches.analysis_cache.len(),
1995 ANALYSIS_CACHE_CAP
1996 );
1997 }
1998
1999 #[test]
2000 fn get_doc_salsa_cache_hits_across_calls() {
2001 let store = DocumentStore::new();
2002 let u = uri("/g3_cache.php");
2003 open(&store, u.clone(), "<?php\nclass G3 {}".to_string());
2004
2005 let a = store.get_doc_salsa(&u).unwrap();
2006 let b = store.get_doc_salsa(&u).unwrap();
2007 assert!(
2008 Arc::ptr_eq(&a, &b),
2009 "parsed_cache hit should yield the same Arc across calls"
2010 );
2011
2012 open(&store, u.clone(), "<?php\nclass G3b {}".to_string());
2013 let c = store.get_doc_salsa(&u).unwrap();
2014 assert!(
2015 !Arc::ptr_eq(&a, &c),
2016 "edit should invalidate the parsed_cache entry"
2017 );
2018 }
2019
2020 #[test]
2021 fn get_doc_salsa_returns_some_for_mirrored_files() {
2022 let store = DocumentStore::new();
2025 let u = uri("/e4_doc.php");
2026 store.ingest(u.clone(), "<?php\nclass P {}");
2027 assert!(store.get_doc_salsa(&u).is_some());
2028 }
2029
2030 #[test]
2031 fn get_doc_snapshot_or_stale_matches_salsa_on_settled_buffer() {
2032 let store = DocumentStore::new();
2033 let u = uri("/stale_settled.php");
2034 open(&store, u.clone(), "<?php\nclass S {}".to_string());
2035 let fresh = store.get_doc_salsa(&u).unwrap();
2036 let stale = store.get_doc_snapshot_or_stale(&u).unwrap();
2037 assert!(
2038 Arc::ptr_eq(&fresh, &stale),
2039 "on a settled buffer the stale-tolerant accessor must serve the cached parse"
2040 );
2041 }
2042
2043 #[test]
2047 fn get_doc_snapshot_or_stale_stays_responsive_under_writes() {
2048 use std::sync::Arc;
2049 use std::thread;
2050 use std::time::{Duration, Instant};
2051
2052 let store = Arc::new(DocumentStore::new());
2053 let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/hl{i}.php"))).collect();
2054 for (i, u) in urls.iter().enumerate() {
2055 open(&store, u.clone(), format!("<?php\nclass H{i} {{}}"));
2056 assert!(store.get_doc_salsa(u).is_some());
2058 }
2059
2060 let deadline = Instant::now() + Duration::from_millis(400);
2061 let mut handles = Vec::new();
2062
2063 {
2064 let store = Arc::clone(&store);
2065 let urls = urls.clone();
2066 handles.push(thread::spawn(move || {
2067 let mut rev = 0u32;
2068 while Instant::now() < deadline {
2069 for u in &urls {
2070 store.mirror_text(u, &format!("<?php\nclass H {{}}\n// rev {rev}"));
2071 }
2072 rev += 1;
2073 }
2074 }));
2075 }
2076
2077 for _ in 0..4 {
2078 let store = Arc::clone(&store);
2079 let urls = urls.clone();
2080 handles.push(thread::spawn(move || {
2081 while Instant::now() < deadline {
2082 for u in &urls {
2083 assert!(
2084 store.get_doc_snapshot_or_stale(u).is_some(),
2085 "open file must resolve to a fresh or stale parse, never None"
2086 );
2087 }
2088 }
2089 }));
2090 }
2091
2092 for h in handles {
2093 h.join()
2094 .expect("stale accessor must not panic or spin under concurrent writes");
2095 }
2096 }
2097
2098 #[test]
2103 #[ignore = "timing measurement; run with --ignored --nocapture"]
2104 fn ws1_ab_latency_under_write_pressure() {
2105 use std::sync::Arc;
2106 use std::sync::atomic::{AtomicBool, Ordering};
2107 use std::thread;
2108 use std::time::{Duration, Instant};
2109
2110 fn big_source(rev: u32) -> String {
2114 let mut s = String::from("<?php\nclass Big {\n");
2115 for i in 0..3000 {
2116 s.push_str(&format!(
2117 " public function m{i}(int $a, string $b): int {{ return $a + {i}; }}\n"
2118 ));
2119 }
2120 s.push_str(&format!("}}\n// rev {rev}\n"));
2121 s
2122 }
2123
2124 fn measure(
2125 store: &Arc<DocumentStore>,
2126 url: &Url,
2127 writers: usize,
2128 label: &str,
2129 call: impl Fn(&DocumentStore, &Url) -> bool,
2130 ) {
2131 let stop = Arc::new(AtomicBool::new(false));
2132 let mut writer_handles = Vec::new();
2133 for w in 0..writers {
2134 let store = Arc::clone(store);
2135 let url = url.clone();
2136 let stop = Arc::clone(&stop);
2137 writer_handles.push(thread::spawn(move || {
2138 let mut rev = (w as u32) * 1_000_000;
2139 while !stop.load(Ordering::Relaxed) {
2140 store.mirror_text(&url, &big_source(rev));
2141 rev = rev.wrapping_add(1);
2142 }
2143 }));
2144 }
2145 thread::sleep(Duration::from_millis(30)); let n = 100usize;
2148 let mut max = Duration::ZERO;
2149 let mut total = Duration::ZERO;
2150 for _ in 0..n {
2151 let t = Instant::now();
2152 let ok = call(store, url);
2153 let dt = t.elapsed();
2154 assert!(ok, "{label}: accessor returned None");
2155 max = max.max(dt);
2156 total += dt;
2157 }
2158 stop.store(true, Ordering::Relaxed);
2159 for h in writer_handles {
2160 h.join().unwrap();
2161 }
2162 println!(
2163 "{label}: {n} calls, {writers} writers — max {:>10.3?}, mean {:>10.3?}",
2164 max,
2165 total / n as u32
2166 );
2167 }
2168
2169 let store = Arc::new(DocumentStore::new());
2170 let url = uri("/perf_big.php");
2171 open(&store, url.clone(), big_source(0));
2172 assert!(store.get_doc_salsa(&url).is_some()); for writers in [2usize, 4] {
2175 measure(
2176 &store,
2177 &url,
2178 writers,
2179 "NEW get_doc_snapshot_or_stale",
2180 |s, u| s.get_doc_snapshot_or_stale(u).is_some(),
2181 );
2182 measure(
2183 &store,
2184 &url,
2185 writers,
2186 "OLD get_doc_salsa (unbounded) ",
2187 |s, u| s.get_doc_salsa(u).is_some(),
2188 );
2189 }
2190 }
2191
2192 #[test]
2193 fn get_salsa_accessors_return_none_for_unknown_uri() {
2194 let store = DocumentStore::new();
2195 let u = uri("/never-seen.php");
2196 assert!(store.get_doc_salsa(&u).is_none());
2197 assert!(store.get_index_salsa(&u).is_none());
2198 }
2199
2200 #[test]
2208 fn concurrent_reads_and_writes_do_not_panic() {
2209 use std::sync::Arc;
2210 use std::thread;
2211 use std::time::{Duration, Instant};
2212
2213 let store = Arc::new(DocumentStore::new());
2214 let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/f{i}.php"))).collect();
2215 for (i, u) in urls.iter().enumerate() {
2216 open(&store, u.clone(), format!("<?php\nclass C{i} {{}}"));
2217 }
2218
2219 let deadline = Instant::now() + Duration::from_millis(400);
2220 let mut handles = Vec::new();
2221
2222 {
2224 let store = Arc::clone(&store);
2225 let urls = urls.clone();
2226 handles.push(thread::spawn(move || {
2227 let mut rev = 0u32;
2228 while Instant::now() < deadline {
2229 for u in &urls {
2230 let text = format!("<?php\nclass C{{}}\n// rev {rev}");
2231 store.mirror_text(u, &text);
2232 }
2233 rev += 1;
2234 }
2235 }));
2236 }
2237
2238 for _ in 0..4 {
2240 let store = Arc::clone(&store);
2241 let urls = urls.clone();
2242 handles.push(thread::spawn(move || {
2243 while Instant::now() < deadline {
2244 for u in &urls {
2245 let _ = store.get_doc_salsa(u);
2246 let _ = store.get_index_salsa(u);
2247 }
2248 }
2252 }));
2253 }
2254
2255 for h in handles {
2256 h.join().expect("no panic under concurrent read/write");
2257 }
2258 }
2259
2260 #[test]
2264 fn psr4_lazy_load_suppresses_undefined_class() {
2265 let tmp = tempfile::tempdir().unwrap();
2266
2267 std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
2269 std::fs::write(
2270 tmp.path().join("src/Model/Entity.php"),
2271 "<?php\nnamespace App\\Model;\nclass Entity {}\n",
2272 )
2273 .unwrap();
2274
2275 std::fs::write(
2277 tmp.path().join("composer.json"),
2278 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
2279 )
2280 .unwrap();
2281
2282 let store = DocumentStore::new();
2283
2284 store
2286 .psr4
2287 .store(Arc::new(crate::lang::autoload::Psr4Map::load(tmp.path())));
2288
2289 let handler_url = Url::from_file_path(tmp.path().join("src/Service/Handler.php")).unwrap();
2293 store.mirror_text(
2294 &handler_url,
2295 "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\nfunction handle(Entity $e): Entity { return $e; }\n",
2296 );
2297
2298 let issues = store.get_semantic_issues_salsa(&handler_url).unwrap();
2299 let undef: Vec<_> = issues
2300 .iter()
2301 .filter(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. }))
2302 .collect();
2303 assert!(
2304 undef.is_empty(),
2305 "PSR-4 lazy-loading must prevent UndefinedClass for App\\Model\\Entity; got: {undef:?}"
2306 );
2307 }
2308
2309 #[test]
2324 fn all_docs_for_scan_does_not_reparse_indexed_files() {
2325 let store = DocumentStore::new();
2326 const N: usize = 50;
2327 for i in 0..N {
2328 let u = uri(&format!("/scan/file{i}.php"));
2329 store.ingest(u, &format!("<?php\nclass C{i} {{}}\nfunction f{i}() {{}}"));
2330 }
2331
2332 let first: Vec<_> = store.all_docs_for_scan();
2333 let second: Vec<_> = store.all_docs_for_scan();
2334 assert_eq!(first.len(), N);
2335 assert_eq!(second.len(), N);
2336
2337 let by_url_first: std::collections::HashMap<Url, Arc<ParsedDoc>> =
2338 first.into_iter().collect();
2339 for (u, doc2) in second {
2340 let doc1 = by_url_first
2341 .get(&u)
2342 .expect("second scan returned a URL the first didn't");
2343 assert!(
2344 Arc::ptr_eq(doc1, &doc2),
2345 "{u} re-parsed across all_docs_for_scan calls — \
2346 cache (parsed_cache + salsa parsed_doc memo) failed to hit"
2347 );
2348 }
2349
2350 let edited_url = uri("/scan/file0.php");
2353 let pre_edit = store.get_doc_salsa(&edited_url).unwrap();
2354 store.ingest(edited_url.clone(), "<?php\nclass C0Edited {}");
2355 let post_edit = store.get_doc_salsa(&edited_url).unwrap();
2356 assert!(
2357 !Arc::ptr_eq(&pre_edit, &post_edit),
2358 "edited file must produce a fresh ParsedDoc"
2359 );
2360 for i in 1..N {
2361 let u = uri(&format!("/scan/file{i}.php"));
2362 let original = by_url_first.get(&u).unwrap();
2363 let after = store.get_doc_salsa(&u).unwrap();
2364 assert!(
2365 Arc::ptr_eq(original, &after),
2366 "{u} should not have re-parsed because of an unrelated edit"
2367 );
2368 }
2369 }
2370
2371 #[test]
2376 fn body_only_edit_does_not_invalidate_sibling_analysis_cache() {
2377 let store = DocumentStore::new();
2378 let ua = uri("/ic_a.php");
2379 let ub = uri("/ic_b.php");
2380
2381 open(
2383 &store,
2384 ua.clone(),
2385 "<?php\nfunction a() { return 1; }".to_string(),
2386 );
2387 open(
2388 &store,
2389 ub.clone(),
2390 "<?php\nfunction b() { return 2; }".to_string(),
2391 );
2392 let _ = store.cached_analysis(&ua).unwrap();
2393 let analysis_b_first = store.cached_analysis(&ub).unwrap();
2394 let ver_after_warm = store.caches.decl_version();
2395
2396 store.mirror_text(&ua, "<?php\nfunction a() { return 999; }");
2398 let _ = store.cached_analysis(&ua);
2399 let ver_after_body_edit = store.caches.decl_version();
2400 assert_eq!(
2401 ver_after_warm, ver_after_body_edit,
2402 "body-only edit must not bump decl_version"
2403 );
2404
2405 let analysis_b_second = store.cached_analysis_if_fresh(&ub);
2407 assert!(
2408 analysis_b_second.is_some(),
2409 "B's analysis should hit cache after body-only edit to A"
2410 );
2411 assert!(
2412 Arc::ptr_eq(&analysis_b_first, &analysis_b_second.unwrap()),
2413 "B's analysis should be the identical Arc (no re-analysis)"
2414 );
2415
2416 store.mirror_text(&ua, "<?php\nfunction a_renamed() { return 999; }");
2418 let _ = store.cached_analysis(&ua);
2419 let ver_after_decl_edit = store.caches.decl_version();
2420 assert!(
2421 ver_after_decl_edit > ver_after_body_edit,
2422 "declaration edit must bump decl_version (was {ver_after_body_edit}, now {ver_after_decl_edit})"
2423 );
2424
2425 let analysis_b_stale = store.cached_analysis_if_fresh(&ub);
2427 assert!(
2428 analysis_b_stale.is_none(),
2429 "B's analysis should be stale after A's declaration changed"
2430 );
2431 }
2432
2433 #[test]
2437 fn snapshot_query_survives_concurrent_writes() {
2438 use std::sync::Arc;
2439 use std::thread;
2440 use std::time::{Duration, Instant};
2441
2442 let store = Arc::new(DocumentStore::new());
2443 let u = uri("/sq_test.php");
2444 open(
2445 &store,
2446 u.clone(),
2447 "<?php\nfunction f(): int { return 1; }".to_string(),
2448 );
2449
2450 let deadline = Instant::now() + Duration::from_millis(200);
2451 let mut handles = Vec::new();
2452
2453 {
2455 let store = Arc::clone(&store);
2456 let u = u.clone();
2457 handles.push(thread::spawn(move || {
2458 let mut rev = 0u32;
2459 while Instant::now() < deadline {
2460 store.mirror_text(&u, &format!("<?php\nfunction f(): int {{ return {rev}; }}"));
2461 rev += 1;
2462 }
2463 }));
2464 }
2465
2466 for _ in 0..4 {
2468 let store = Arc::clone(&store);
2469 let u = u.clone();
2470 handles.push(thread::spawn(move || {
2471 while Instant::now() < deadline {
2472 let _ = store.get_doc_salsa(&u);
2473 let _ = store.get_index_salsa(&u);
2474 }
2475 }));
2476 }
2477
2478 for h in handles {
2479 h.join()
2480 .expect("no panic in snapshot_query under concurrent writes");
2481 }
2482 }
2483
2484 #[test]
2488 fn owned_program_cache_reused_after_sibling_declaration_change() {
2489 let store = DocumentStore::new();
2490 let ua = uri("/prog_a.php");
2491 let ub = uri("/prog_b.php");
2492
2493 open(
2494 &store,
2495 ua.clone(),
2496 "<?php\nfunction alpha(): void {}".to_string(),
2497 );
2498 open(
2499 &store,
2500 ub.clone(),
2501 "<?php\nfunction beta(): void {}".to_string(),
2502 );
2503
2504 let _ = store.cached_analysis(&ua);
2506 let _ = store.cached_analysis(&ub);
2507
2508 let prog_b_first = store
2510 .caches
2511 .owned_program_cache
2512 .get(&ub)
2513 .map(|e| Arc::clone(&e.1))
2514 .expect("B's owned_program should be cached after first analysis");
2515
2516 store.mirror_text(&ua, "<?php\nfunction alpha_renamed(): void {}");
2518 let _ = store.cached_analysis(&ua);
2520
2521 let _ = store.cached_analysis(&ub);
2523
2524 let prog_b_second = store
2525 .caches
2526 .owned_program_cache
2527 .get(&ub)
2528 .map(|e| Arc::clone(&e.1))
2529 .expect("B's owned_program should still be cached after sibling edit");
2530
2531 assert!(
2532 Arc::ptr_eq(&prog_b_first, &prog_b_second),
2533 "B's owned_program Arc should be identical (cache hit) after sibling declaration change"
2534 );
2535 }
2536
2537 #[test]
2539 fn write_rev_increments_on_write() {
2540 let store = DocumentStore::new();
2541 let u = uri("/rev_test.php");
2542 let before = store.write_rev();
2543 store.mirror_text(&u, "<?php echo 1;");
2544 let after_first = store.write_rev();
2545 assert!(after_first > before, "first write must bump the revision");
2546 store.mirror_text(&u, "<?php echo 1;");
2548 assert_eq!(
2549 store.write_rev(),
2550 after_first,
2551 "identical text must not bump the revision"
2552 );
2553 store.mirror_text(&u, "<?php echo 2;");
2555 assert!(
2556 store.write_rev() > after_first,
2557 "changed text must bump the revision"
2558 );
2559 }
2560
2561 #[test]
2564 fn begin_reanalyze_cancels_previous_token() {
2565 let store = DocumentStore::new();
2566 let first = store.begin_reanalyze();
2567 assert!(!first.is_cancelled(), "a fresh token starts un-cancelled");
2568 let second = store.begin_reanalyze();
2569 assert!(
2570 first.is_cancelled(),
2571 "issuing a new token must cancel the previous in-flight sweep"
2572 );
2573 assert!(
2574 !second.is_cancelled(),
2575 "the newly-issued token must itself be un-cancelled"
2576 );
2577 }
2578
2579 #[test]
2581 fn write_rev_increments_on_remove() {
2582 let store = DocumentStore::new();
2583 let u = uri("/rev_remove.php");
2584 store.mirror_text(&u, "<?php class A {}");
2585 let before = store.write_rev();
2586 store.remove(&u);
2587 assert!(store.write_rev() > before, "remove must bump the revision");
2588 }
2589}