Skip to main content

mir_analyzer/session/
snapshot.rs

1use std::panic::AssertUnwindSafe;
2use std::sync::Arc;
3
4use rustc_hash::FxHashSet as HashSet;
5use salsa::Cancelled;
6
7use super::index_state::{
8    hash_files, AnalyzedFile, DefsFreshness, IndexState, QueryGeneration, RefFreshness,
9    RefQueryCacheKey, RetireEpoch, SubtypeQueryCacheKey, ViewStamp,
10};
11use super::queries::{identifier_char_col, span_range, ReferenceGate};
12use super::SubtypeClassSite;
13use crate::cache::AnalysisCache;
14use crate::db::{MirDatabase, MirDbStorage};
15use crate::php_version::PhpVersion;
16
17/// A read handle on one revision of an [`super::AnalysisSession`]:
18/// `Send + Clone`, for running queries on threads other than the owner's.
19///
20/// No query writes salsa inputs. Class-likes and built-in stubs the symbol
21/// index lacks load on demand as pure reads; the owner adopts them at its
22/// next [`super::AnalysisSession::prepare_for_query`], which also settles
23/// the index before a snapshot is handed out.
24///
25/// A snapshot blocks the owner's next input write until it is dropped (salsa
26/// waits for outstanding handles), so hand snapshots out per request and
27/// never park one. When the owner starts a write, in-flight queries unwind
28/// and return `Err(Cancelled)`: drop the snapshot and request a fresh one.
29///
30/// Queries may still commit to the shared reference/subtype indexes (lazy
31/// freshness passes); those commits land in the owner's state too.
32#[derive(Clone)]
33pub struct AnalysisSnapshot {
34    pub(super) db: MirDbStorage,
35    pub(super) index: Arc<IndexState>,
36    pub(super) cache: Option<Arc<AnalysisCache>>,
37    pub(super) php_version: PhpVersion,
38    /// [`Self::index_generation`] captured when the owner created the
39    /// snapshot, while no write could race it.
40    pub(super) index_generation: u64,
41    /// Retirements this snapshot's view already reflects; commits for files
42    /// retired later are refused.
43    pub(super) retire_epoch: RetireEpoch,
44}
45
46const _: () = {
47    const fn assert_send_clone<T: Send + Clone>() {}
48    assert_send_clone::<AnalysisSnapshot>();
49};
50
51/// An [`AnalysisSnapshot`] that borrows its owner, so the owner's next
52/// `&mut self` write can't start while it's alive. A live handle across that
53/// write would deadlock the owner in salsa's `cancel_others`.
54pub(crate) struct DbView<'a> {
55    snapshot: AnalysisSnapshot,
56    _owner: std::marker::PhantomData<&'a super::AnalysisSession>,
57}
58
59impl<'a> DbView<'a> {
60    pub(super) fn new(_owner: &'a super::AnalysisSession, snapshot: AnalysisSnapshot) -> Self {
61        Self {
62            snapshot,
63            _owner: std::marker::PhantomData,
64        }
65    }
66}
67
68// Without a `Drop` impl, NLL ends the owner borrow at the view's last use
69// while its db clone lives on to the end of scope.
70struct ConstantDeclSite {
71    file: Arc<str>,
72    line: u32,
73    stmt_col: u32,
74    stmt_end: u32,
75    name_col: u32,
76    name_len: u32,
77}
78
79impl Drop for DbView<'_> {
80    fn drop(&mut self) {}
81}
82
83impl std::ops::Deref for DbView<'_> {
84    type Target = AnalysisSnapshot;
85    fn deref(&self) -> &AnalysisSnapshot {
86        &self.snapshot
87    }
88}
89
90/// Analyses staged by [`AnalysisSnapshot::stage_warm`], awaiting commit.
91struct WarmPass {
92    mention_scanner: Option<Arc<crate::db::class_mention_index::MentionScanner>>,
93    analyzed: Vec<AnalyzedFile>,
94}
95
96/// A fallback mention scan `(file, text scanned, names found)`, recorded
97/// after the pass so later gate checks become set lookups.
98type MentionScanRecord = (Arc<str>, Arc<str>, Box<[mir_types::Name]>);
99
100pub(super) fn catch<T>(f: impl FnOnce() -> T) -> Result<T, Cancelled> {
101    Cancelled::catch(AssertUnwindSafe(f))
102}
103
104/// Raise `cancelled` the way salsa does, for code already inside [`catch`].
105pub(super) fn unwind(cancelled: Cancelled) -> ! {
106    std::panic::resume_unwind(Box::new(cancelled))
107}
108
109impl AnalysisSnapshot {
110    /// The salsa database this snapshot reads.
111    ///
112    /// **Internal API — exposes Salsa types.** Subject to change without
113    /// notice. Queries on it unwind with [`Cancelled`] rather than returning
114    /// it; use [`Self::read`] to catch that.
115    #[doc(hidden)]
116    pub fn db(&self) -> &MirDbStorage {
117        &self.db
118    }
119
120    /// Run `f` against the database, catching cancellation.
121    ///
122    /// **Internal API — exposes Salsa types.** Subject to change without
123    /// notice.
124    #[doc(hidden)]
125    pub fn read<R>(&self, f: impl FnOnce(&dyn MirDatabase) -> R) -> Result<R, Cancelled> {
126        catch(|| f(&self.db))
127    }
128
129    pub(crate) fn index(&self) -> &IndexState {
130        &self.index
131    }
132
133    pub(crate) fn stamp(&self) -> ViewStamp {
134        ViewStamp {
135            generation: self.index_generation,
136            retire_epoch: self.retire_epoch,
137        }
138    }
139
140    pub fn php_version(&self) -> PhpVersion {
141        self.php_version
142    }
143
144    /// See [`super::AnalysisSession::index_generation`].
145    pub fn index_generation(&self) -> u64 {
146        self.index_generation
147    }
148
149    /// See [`super::AnalysisSession::text_revision`].
150    pub fn text_revision(&self) -> salsa::Revision {
151        self.db.current_revision()
152    }
153
154    /// The combined generation the query memos key on: salsa's text revision
155    /// plus the off-salsa subtype-edge epoch and retirement seq. The epoch
156    /// covers what the revision can't — subtype edges and anonymous-class
157    /// `impl:` postings committed *within* one revision (e.g. a subtype BFS
158    /// admitting a file the reference gate skipped), which change a member
159    /// query's hierarchy fan-out without any text write. The seq covers a
160    /// retire whose ingest writes nothing, so a query that read the cleared
161    /// postings is never memoized.
162    pub(crate) fn query_cache_generation(&self) -> QueryGeneration {
163        (
164            self.db.current_revision(),
165            self.db.subtype_edges_epoch(),
166            self.index.retire_seq(),
167        )
168    }
169
170    pub fn find_class_like(&self, fqcn: &str) -> Result<Option<crate::db::ClassLike>, Cancelled> {
171        self.read(|db| crate::db::find_class_like(db, crate::db::Fqcn::from_str(db, fqcn)))
172    }
173
174    /// Whether `fqcn` resolves to a class-like declared by an embedded
175    /// built-in stub. A workspace class sharing a stub's name (a polyfill's
176    /// `Stringable`) shadows it and answers `false`, where
177    /// [`crate::stub_path_for_class`] tests the name alone.
178    pub fn is_builtin_class(&self, fqcn: &str) -> Result<bool, Cancelled> {
179        self.read(|db| {
180            let fqcn = fqcn.trim_start_matches('\\');
181            crate::db::class_like_decl_file(db, crate::db::Fqcn::from_str(db, fqcn))
182                .is_some_and(|file| crate::stubs::is_stub_path(&file))
183        })
184    }
185
186    pub fn find_function(&self, fqn: &str) -> Result<Option<Arc<crate::FunctionDef>>, Cancelled> {
187        self.read(|db| crate::db::find_function(db, crate::db::Fqcn::from_str(db, fqn)))
188    }
189
190    /// `method` as resolved along `class`'s ancestor chain (then mixins), as
191    /// `(declaring class, method)`.
192    #[allow(clippy::type_complexity)]
193    pub fn find_method_in_chain(
194        &self,
195        class: &str,
196        method: &str,
197    ) -> Result<Option<(Arc<str>, Arc<mir_codebase::definitions::MethodDef>)>, Cancelled> {
198        self.read(|db| {
199            crate::db::find_method_in_chain(db, crate::db::Fqcn::from_str(db, class), method)
200        })
201    }
202
203    /// The interface or abstract method that `method` implements: the first
204    /// ancestor of its class declaring the same-named method as abstract or
205    /// as an interface member. `None` for non-methods and for methods that
206    /// implement nothing.
207    pub fn implemented_method(
208        &self,
209        method: &crate::Name,
210    ) -> Result<Option<crate::Name>, Cancelled> {
211        let crate::Name::Method { class, name } = method else {
212            return Ok(None);
213        };
214        self.read(|db| {
215            let here = crate::db::Fqcn::from_str(db, class.as_ref());
216            crate::db::class_ancestors_by_fqcn(db, here)
217                .iter()
218                .skip(1)
219                .find_map(|ancestor| {
220                    let fqcn = crate::db::Fqcn::from_str(db, ancestor.as_ref());
221                    let is_interface = matches!(
222                        crate::db::find_class_like(db, fqcn)?,
223                        crate::db::ClassLike::Interface(_)
224                    );
225                    let m = crate::db::find_method_in_class(db, fqcn, name)?;
226                    (is_interface || m.is_abstract)
227                        .then(|| crate::Name::method(ancestor.clone(), name))
228                })
229        })
230    }
231
232    /// Declaration location of `symbol`. Symbols the index lacks load on
233    /// demand; no input is written.
234    pub fn definition_of_cached(
235        &self,
236        symbol: &crate::Name,
237    ) -> Result<Result<mir_types::Location, crate::SymbolLookupError>, Cancelled> {
238        catch(|| self.definition_of_unguarded(symbol))
239    }
240
241    fn definition_of_unguarded(
242        &self,
243        symbol: &crate::Name,
244    ) -> Result<mir_types::Location, crate::SymbolLookupError> {
245        let db = &self.db;
246        match symbol {
247            crate::Name::Class(fqcn) => {
248                let here = crate::db::Fqcn::from_str(db, fqcn.as_ref());
249                let class = crate::db::find_class_like(db, here)
250                    .ok_or(crate::SymbolLookupError::NotFound)?;
251                class
252                    .location()
253                    .cloned()
254                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
255            }
256            crate::Name::Function(fqn) => {
257                let here = crate::db::Fqcn::from_str(db, fqn.as_ref());
258                let f =
259                    crate::db::find_function(db, here).ok_or(crate::SymbolLookupError::NotFound)?;
260                f.location
261                    .clone()
262                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
263            }
264            crate::Name::Method { .. }
265            | crate::Name::Property { .. }
266            | crate::Name::ClassConstant { .. } => {
267                crate::db::member_location(db, symbol).ok_or(crate::SymbolLookupError::NotFound)
268            }
269            crate::Name::GlobalConstant(fqn) => self
270                .global_constant_location(fqn)
271                .ok_or(crate::SymbolLookupError::NoSourceLocation),
272        }
273    }
274
275    /// Which of `files` currently mention `class_name` as a whole identifier
276    /// (case-insensitive) — the same persistent, incrementally-maintained
277    /// mechanism [`Self::indexed_references_to`]'s own gate uses (see
278    /// `db::class_mention_index`), exposed so a host needn't re-implement a
279    /// from-scratch text scan for its own narrowing. A file already scanned
280    /// against a universe that included `class_name` answers via an O(log n)
281    /// lookup; only a never-scanned or since-edited file pays a scan, and
282    /// that scan is recorded for every other name in the universe too.
283    pub fn files_mentioning_class(
284        &self,
285        files: &[Arc<str>],
286        class_name: &str,
287    ) -> Result<Vec<Arc<str>>, Cancelled> {
288        self.files_mentioning_any(files, &[class_name])
289    }
290
291    /// Which of `files` currently mention ANY of `needles` as a whole
292    /// identifier (case-insensitive). Every needle is admitted into the
293    /// shared universe verbatim (no short-name stripping) before querying,
294    /// so the answer is never conservative on account of an unknown needle.
295    pub fn files_mentioning_any(
296        &self,
297        files: &[Arc<str>],
298        needles: &[&str],
299    ) -> Result<Vec<Arc<str>>, Cancelled> {
300        catch(|| self.mentioning_any_unguarded(files, needles))
301    }
302
303    fn mentioning_any_unguarded(&self, files: &[Arc<str>], needles: &[&str]) -> Vec<Arc<str>> {
304        use rayon::prelude::*;
305
306        if needles.is_empty() {
307            return files.to_vec();
308        }
309        let db = self.db.clone();
310        db.add_literal_mention_names(needles.iter().copied());
311        // Admission just grew the universe, so these only fail defensively.
312        let queries: Vec<_> = needles
313            .iter()
314            .filter_map(|n| db.prepare_class_mention_query(n))
315            .collect();
316        if queries.is_empty() {
317            return files.to_vec();
318        }
319        let Some(scanner) = db.class_mention_scanner() else {
320            return files.to_vec();
321        };
322
323        files
324            .par_iter()
325            .map_with(db, |db, f| -> Option<Arc<str>> {
326                let sf = db.lookup_source_file(f.as_ref())?;
327                let text = sf.text(&*db as &dyn MirDatabase).clone();
328                for q in &queries {
329                    match db.class_mention_answer(f.as_ref(), q, &text) {
330                        Some(true) => return Some(f.clone()),
331                        Some(false) => continue,
332                        None => {
333                            let names = scanner.scan(text.as_ref());
334                            let hit = queries
335                                .iter()
336                                .any(|q2| names.binary_search(&q2.name).is_ok());
337                            db.set_file_class_mentions(f, &text, scanner.epoch(), names);
338                            return hit.then(|| f.clone());
339                        }
340                    }
341                }
342                None
343            })
344            .flatten()
345            .collect()
346    }
347
348    /// Inverted-index find-references: posting-list lookup plus an on-demand
349    /// freshness/completeness pass over `files` (the host's candidate scope
350    /// — passing the whole workspace is fine; see the gate in
351    /// `stale_reference_candidates_unguarded`).
352    ///
353    /// A candidate whose postings were committed from its current text (Arc
354    /// identity) is answered from the index with no salsa work. Stale or
355    /// never-committed candidates are analyzed via the memoized
356    /// `analyze_file` query and committed, so each file pays that cost once
357    /// per text change — after a background warm sweep the steady state is a
358    /// pure lookup, O(results) instead of O(candidates). Hosts need no text
359    /// prefilter of their own, and must not use one: a host-side filter
360    /// cannot know the gate's matching semantics.
361    ///
362    /// Results are filtered to `files`. With `include_declaration`, the
363    /// symbol's declaration name span is appended when it lies inside the
364    /// scope.
365    ///
366    /// Returned ranges use mir's native coordinates: 1-based lines and
367    /// 0-based Unicode code-point columns (UTF-32/LSP `positionEncoding`
368    /// `"utf-32"`).
369    ///
370    /// Stale candidates are analyzed against whatever the owner has loaded.
371    /// Classes load on demand, but Composer `autoload.files` functions only
372    /// load in the owner's warm-up: a candidate analyzed before it records
373    /// unresolved names and is re-verified after the owner's next load bumps
374    /// the generation. Run [`super::AnalysisSession::prepare_references_query`]
375    /// before taking the snapshot to warm the stale candidates first.
376    ///
377    /// Memoized per `(symbol, files, include_declaration, includes,
378    /// query generation)` — see `RefQueryCacheKey` — so a repeat query
379    /// against unchanged state (a code-lens refresh) is one hashmap lookup
380    /// instead of an O(candidates) freshness scan.
381    pub fn indexed_references_to(
382        &self,
383        symbol: &crate::Name,
384        files: &[Arc<str>],
385        include_declaration: bool,
386        includes: crate::ReferenceIncludes,
387    ) -> Result<Vec<(Arc<str>, crate::Range)>, Cancelled> {
388        let key = self.reference_query_key(symbol, files, include_declaration, includes);
389        if let Some(hit) = self.index.ref_queries.get(&key) {
390            return Ok(hit);
391        }
392        let stale = catch(|| self.stale_reference_candidates_unguarded(symbol, files))?;
393        if !stale.is_empty() {
394            self.commit_reference_candidates(&stale)?;
395        }
396        let out = catch(|| self.read_references(symbol, files, include_declaration, includes))?;
397        self.ensure_no_retire_since(key.generation)?;
398        self.memoize_references(key, &out);
399        Ok(out)
400    }
401
402    /// `Err` when the owner retired a file since `generation`: postings read
403    /// in between may lack that file, and its replacing write is under way.
404    pub(super) fn ensure_no_retire_since(
405        &self,
406        generation: QueryGeneration,
407    ) -> Result<(), Cancelled> {
408        if self.index.retire_seq() == generation.2 {
409            Ok(())
410        } else {
411            Err(Cancelled::PendingWrite)
412        }
413    }
414
415    /// `use` import items (`use Foo\Bar;`, `use function ...;`,
416    /// `use const ...;`) for `symbol` — [`Self::indexed_references_to`] with
417    /// [`crate::ReferenceIncludes::UseImports`].
418    pub fn indexed_use_import_locations(
419        &self,
420        symbol: &crate::Name,
421        files: &[Arc<str>],
422    ) -> Result<Vec<(Arc<str>, crate::Range)>, Cancelled> {
423        self.indexed_references_to(symbol, files, false, crate::ReferenceIncludes::UseImports)
424    }
425
426    pub(super) fn reference_query_key(
427        &self,
428        symbol: &crate::Name,
429        files: &[Arc<str>],
430        include_declaration: bool,
431        includes: crate::ReferenceIncludes,
432    ) -> RefQueryCacheKey {
433        RefQueryCacheKey {
434            symbol: symbol.codebase_key(),
435            include_declaration,
436            includes,
437            generation: self.query_cache_generation(),
438            files_hash: hash_files(files),
439        }
440    }
441
442    /// Cache `out` unless the generation moved while computing it (an edit,
443    /// or a defs commit growing the subtype index — possibly this query's
444    /// own): such a key can never be looked up again, and the next identical
445    /// query recomputes once against settled state and caches then.
446    pub(super) fn memoize_references(
447        &self,
448        key: RefQueryCacheKey,
449        out: &[(Arc<str>, crate::Range)],
450    ) {
451        let generation = self.query_cache_generation();
452        if generation == key.generation {
453            self.index.ref_queries.insert(generation, key, out);
454        }
455    }
456
457    /// The candidates in `files` that [`Self::indexed_references_to`] would
458    /// analyze and commit: those whose postings are not exact for their
459    /// current text. Empty means the owner-side prelude
460    /// ([`super::AnalysisSession::prepare_references_query`]) has nothing to
461    /// warm, so a host can check this off its lock and skip taking it; when
462    /// not empty it passes this list to the prelude, so the O(candidates)
463    /// scan never runs under the lock.
464    pub fn stale_reference_candidates(
465        &self,
466        symbol: &crate::Name,
467        files: &[Arc<str>],
468    ) -> Result<Vec<Arc<str>>, Cancelled> {
469        catch(|| self.stale_reference_candidates_unguarded(symbol, files))
470    }
471
472    /// Freshness pass: candidates whose postings are not exact for their
473    /// current text. Files not registered as `SourceFile` inputs are
474    /// skipped. Never-committed files — no commit mark, hence no postings at
475    /// all (every mark drop accompanies a posting clear) — are further gated
476    /// on their text mentioning the symbol's name: such a file can neither
477    /// hold stale postings nor produce new ones, so a cold query on a common
478    /// name skips the bulk of the workspace instead of analyzing it. A
479    /// LIVE-analyzed file stale only by generation gets the same gate.
480    /// Everything else — a genuinely edited file, or a commit seeded by an
481    /// unverified disk-cache replay — re-analyzes unconditionally. Same
482    /// discipline as [`Self::commit_defs_for_matching`] on the defs index.
483    ///
484    /// The whole gate answers from the persistent mention index: every
485    /// needle is admitted, so a recorded mention set answers with lookups
486    /// and only a never-scanned or since-edited file pays one scan against
487    /// the whole universe, recorded for every later consumer. A needle new
488    /// to the universe epoch-invalidates older recordings for itself only.
489    ///
490    /// Intentionally serial: each request already runs on its caller
491    /// thread, and putting every concurrent request back onto the shared
492    /// rayon pool lets an index batch monopolize the workers.
493    pub(super) fn stale_reference_candidates_unguarded(
494        &self,
495        symbol: &crate::Name,
496        files: &[Arc<str>],
497    ) -> Vec<Arc<str>> {
498        let db = &self.db;
499        let gate = self.reference_gate(symbol);
500        let has_needles = !gate.idents.is_empty() || !gate.raw.is_empty();
501        let (mention_queries, mention_scanner) = if has_needles {
502            db.add_literal_mention_names(gate.idents.iter().map(|s| s.as_str()));
503            db.add_raw_mention_needles(gate.raw.iter().map(|s| s.as_str()));
504            let queries: Vec<_> = gate
505                .idents
506                .iter()
507                .chain(gate.raw.iter())
508                .filter_map(|s| db.prepare_class_mention_query(s))
509                .collect();
510            (queries, db.class_mention_scanner())
511        } else {
512            (Vec::new(), None)
513        };
514        // Admission guarantees a query per needle and a non-empty universe;
515        // anything else is defensive — the gate then admits every candidate
516        // (analyze rather than skip, the conservative direction).
517        let gate_complete = mention_queries.len() == gate.idents.len() + gate.raw.len()
518            && mention_scanner.is_some();
519        let current_gen = self.index_generation;
520        let mut stale = Vec::new();
521        let mut scanned: Vec<MentionScanRecord> = Vec::new();
522        for f in files {
523            let Some(sf) = db.lookup_source_file(f.as_ref()) else {
524                continue;
525            };
526            let text = sf.text(db as &dyn MirDatabase);
527            match self.index.ref_freshness(sf, text, current_gen) {
528                RefFreshness::Fresh => continue,
529                RefFreshness::Stale => {
530                    stale.push(f.clone());
531                    continue;
532                }
533                RefFreshness::StaleByGenerationOnly | RefFreshness::Uncommitted => {}
534            }
535            if has_needles && gate_complete {
536                // Any needle answering `true` admits the file; an
537                // unanswerable one forces the single recorded scan, which
538                // settles every needle at once.
539                let mut answer = Some(false);
540                for q in &mention_queries {
541                    match db.class_mention_answer(f.as_ref(), q, text) {
542                        Some(true) => {
543                            answer = Some(true);
544                            break;
545                        }
546                        Some(false) => {}
547                        None => answer = None,
548                    }
549                }
550                match answer {
551                    Some(true) => {}
552                    Some(false) => continue,
553                    None => {
554                        let scanner = mention_scanner
555                            .as_ref()
556                            .expect("gate_complete implies a scanner");
557                        let names = scanner.scan(text);
558                        let hit = mention_queries
559                            .iter()
560                            .any(|q| names.binary_search(&q.name).is_ok());
561                        scanned.push((f.clone(), text.clone(), names));
562                        if hit {
563                            stale.push(f.clone());
564                        }
565                        continue;
566                    }
567                }
568            }
569            stale.push(f.clone());
570        }
571        // Recorded regardless of how the query proceeds: each is a complete,
572        // current mention set for its file.
573        if let Some(scanner) = &mention_scanner {
574            for (file, text, names) in scanned {
575                db.set_file_class_mentions(&file, &text, scanner.epoch(), names);
576            }
577        }
578        stale
579    }
580
581    /// Analyze `stale` and commit their postings and class edges.
582    pub(super) fn commit_reference_candidates(&self, stale: &[Arc<str>]) -> Result<(), Cancelled> {
583        self.warm_pass(stale, &crate::IndexCancel::new()).map(drop)
584    }
585
586    /// Analyze `files` and commit their reference postings, class edges and
587    /// mention scans to the index shared with the owner, so later queries
588    /// find them fresh. `Ok(false)` when `cancel` stopped the pass first.
589    ///
590    /// A commit with unresolved names stays tied to this snapshot's
591    /// [`Self::index_generation`], so the next workspace change re-opens it.
592    pub fn warm_files(
593        &self,
594        files: &[Arc<str>],
595        cancel: &crate::IndexCancel,
596    ) -> Result<bool, Cancelled> {
597        Ok(self.warm_pass(files, cancel)?.is_some())
598    }
599
600    /// The read half of [`super::AnalysisSession::reanalyze_files_cancellable`]:
601    /// analyze `files`, commit their reference postings and class edges, and
602    /// return each file's diagnostics (`symbols` are empty). Files without
603    /// registered text are absent. `Ok(None)` when `cancel` stopped the pass
604    /// first, with nothing committed.
605    ///
606    /// Run the owner-side prelude first: `prepare_for_query(None)`, then
607    /// `prepare_file_for_analysis` per file, a map lookup for files ingested
608    /// with `ingest_file_prepared`.
609    #[allow(clippy::type_complexity)]
610    pub fn reanalyze_files(
611        &self,
612        files: &[Arc<str>],
613        cancel: &crate::IndexCancel,
614    ) -> Result<Option<Vec<(Arc<str>, crate::FileAnalysis)>>, Cancelled> {
615        Ok(self.warm_pass(files, cancel)?.map(|analyzed| {
616            analyzed
617                .into_iter()
618                .map(|a| {
619                    (
620                        a.file,
621                        crate::FileAnalysis {
622                            issues: a.out.issues.to_vec(),
623                            symbols: Vec::new(),
624                        },
625                    )
626                })
627                .collect()
628        }))
629    }
630
631    pub(super) fn warm_pass(
632        &self,
633        files: &[Arc<str>],
634        cancel: &crate::IndexCancel,
635    ) -> Result<Option<Vec<AnalyzedFile>>, Cancelled> {
636        let Some(mut pass) = self.stage_warm(files, cancel)? else {
637            return Ok(None);
638        };
639        self.commit_warm(&mut pass)?;
640        Ok(Some(pass.analyzed))
641    }
642
643    /// Freezes the workspace index on a pass-scoped clone (borrow-only
644    /// symbol lookups + pass-shared subtype cache). On-demand loads leave the
645    /// index untouched and an owner write cancels the pass, so the frozen
646    /// view can't go stale. Same discipline as the batch body pass.
647    fn stage_warm(
648        &self,
649        files: &[Arc<str>],
650        cancel: &crate::IndexCancel,
651    ) -> Result<Option<WarmPass>, Cancelled> {
652        catch(|| {
653            let mut db = self.db.clone();
654            db.freeze_workspace_index();
655            let mention_scanner = db.class_mention_scanner();
656            let cache = self.cache.as_deref();
657            let mut analyzed = Vec::with_capacity(files.len());
658            for path in files {
659                if cancel.is_cancelled() {
660                    return None;
661                }
662                analyzed.extend(self.index.stage_analyzed(
663                    &db,
664                    cache,
665                    mention_scanner.as_deref(),
666                    path,
667                ));
668            }
669            Some(WarmPass {
670                mention_scanner,
671                analyzed,
672            })
673        })
674    }
675
676    /// Marks record the exact text Arc each file was analyzed against, so an
677    /// owner text write racing the pass leaves the file stale, never fresh.
678    fn commit_warm(&self, pass: &mut WarmPass) -> Result<(), Cancelled> {
679        self.index.commit_analyzed(
680            &self.db,
681            self.cache.as_deref(),
682            pass.mention_scanner.as_deref(),
683            &mut pass.analyzed,
684            self.stamp(),
685        )
686    }
687
688    /// Posting lookup for `symbol`, filtered to the candidate scope.
689    ///
690    /// Member symbols resolve against the queried class plus its hierarchy
691    /// (mir records member refs under the *declaring* class, so a query on
692    /// an interface method must include implementor keys and vice versa).
693    /// Name-only fallback postings — receivers whose type couldn't be
694    /// resolved — are consulted only when the typed keys produce nothing:
695    /// exact results when resolution succeeds, by-name matches when nothing
696    /// resolves. `__construct` stays exact: `new Sub()` invokes
697    /// `Sub::__construct` even when only a parent declares one, so hierarchy
698    /// fan-out would wrongly return subtype instantiation sites for a parent
699    /// query.
700    pub(super) fn read_references(
701        &self,
702        symbol: &crate::Name,
703        files: &[Arc<str>],
704        include_declaration: bool,
705        includes: crate::ReferenceIncludes,
706    ) -> Vec<(Arc<str>, crate::Range)> {
707        let key = symbol.codebase_key();
708        let hierarchy: Vec<String> = match symbol {
709            crate::Name::Method { class, name } => {
710                if class.is_empty() {
711                    Vec::new()
712                } else if name.as_ref() == "__construct" {
713                    vec![class.trim_start_matches('\\').to_string()]
714                } else {
715                    self.member_hierarchy_classes(class.as_ref())
716                }
717            }
718            crate::Name::Property { class, .. } | crate::Name::ClassConstant { class, .. } => {
719                if class.is_empty() {
720                    Vec::new()
721                } else {
722                    self.member_hierarchy_classes(class.as_ref())
723                }
724            }
725            _ => Vec::new(),
726        };
727        let scope: HashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
728        let read_symbol_key = |symbol_key: &str| -> Vec<(Arc<str>, crate::Range)> {
729            self.db
730                .reference_locations(symbol_key)
731                .into_iter()
732                .filter(|(file, ..)| scope.contains(file.as_ref()))
733                .map(|(file, line, col_start, col_end)| {
734                    (file, span_range(line, col_start as u32, col_end as u32))
735                })
736                .collect()
737        };
738        let mut scratch_key = String::new();
739        let mut read_composed_key = |prefix: &str, middle: &str, separator: &str, suffix: &str| {
740            scratch_key.clear();
741            scratch_key.reserve(prefix.len() + middle.len() + separator.len() + suffix.len());
742            scratch_key.push_str(prefix);
743            scratch_key.push_str(middle);
744            scratch_key.push_str(separator);
745            scratch_key.push_str(suffix);
746            read_symbol_key(&scratch_key)
747        };
748        let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
749        if !matches!(includes, crate::ReferenceIncludes::UseImports) {
750            let member_prefix = match symbol {
751                crate::Name::Method { name, .. } => Some(("meth:", name)),
752                crate::Name::Property { name, .. } => Some(("prop:", name)),
753                crate::Name::ClassConstant { name, .. } => Some(("cnst:", name)),
754                _ => None,
755            };
756            match member_prefix {
757                Some((prefix, name)) => {
758                    for class in &hierarchy {
759                        out.extend(read_composed_key(prefix, class, "::", name));
760                    }
761                }
762                None => out.extend(read_symbol_key(key.as_str())),
763            }
764        }
765        if !matches!(includes, crate::ReferenceIncludes::Plain) {
766            out.extend(read_composed_key("use:", key.as_str(), "", ""));
767        }
768        if matches!(includes, crate::ReferenceIncludes::Plain) && out.is_empty() {
769            match symbol {
770                crate::Name::Method { name, .. } => {
771                    out = read_composed_key("methname:", name.as_ref(), "", "");
772                }
773                crate::Name::Property { name, .. } => {
774                    out = read_composed_key("propname:", name.as_ref(), "", "");
775                }
776                _ => {}
777            }
778        }
779        out.sort_by(|a, b| {
780            a.0.cmp(&b.0)
781                .then(a.1.start.line.cmp(&b.1.start.line))
782                .then(a.1.start.column.cmp(&b.1.start.column))
783        });
784        out.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
785
786        if include_declaration {
787            let decls: Vec<(Arc<str>, crate::Range)> = match symbol {
788                crate::Name::Method { class, name }
789                | crate::Name::Property { class, name }
790                | crate::Name::ClassConstant { class, name } => {
791                    if class.is_empty() {
792                        // Unknown owner: declarations by name, recorded as
793                        // `methdecl:`/`propdecl:`/`cnstdecl:` postings during
794                        // class/trait/interface/enum analysis.
795                        let prefix = match symbol {
796                            crate::Name::Method { .. } => "methdecl:",
797                            crate::Name::Property { .. } => "propdecl:",
798                            _ => "cnstdecl:",
799                        };
800                        read_composed_key(prefix, name.as_ref(), "", "")
801                    } else {
802                        self.member_decl_sites(&hierarchy, symbol)
803                    }
804                }
805                _ => self.declaration_name_range(symbol).into_iter().collect(),
806            };
807            for (file, range) in decls {
808                if scope.contains(file.as_ref())
809                    && !out.iter().any(|(f, r)| *f == file && *r == range)
810                {
811                    out.push((file, range));
812                }
813            }
814        }
815        out
816    }
817
818    /// The queried class plus every class its members' references could be
819    /// keyed under: resolved ancestors (a call on a subtype instance records
820    /// the declaring ancestor) and transitive subtypes including trait users
821    /// (a call on a subtype that overrides records the subtype). Display-form
822    /// FQCNs, deduplicated case-insensitively.
823    fn member_hierarchy_classes(&self, class_fqn: &str) -> Vec<String> {
824        let target = class_fqn.trim_start_matches('\\');
825        let mut out = vec![target.to_string()];
826        let db = &self.db;
827        let here = crate::db::Fqcn::from_str(db, target);
828        // Ancestors include the class itself first.
829        let ancestors = crate::db::class_ancestors_by_fqcn(db, here)
830            .iter()
831            .skip(1)
832            .map(|a| a.trim_start_matches('\\').to_string())
833            .collect::<Vec<_>>();
834        out.extend(ancestors);
835        out.extend(
836            db.subtype_sites_of(target, true, false)
837                .into_iter()
838                .map(|s| s.fqcn.trim_start_matches('\\').to_string()),
839        );
840        let mut seen: HashSet<String> = HashSet::default();
841        out.retain(|c| seen.insert(c.to_ascii_lowercase()));
842        out
843    }
844
845    /// Own-member declaration sites for `symbol` across `classes`: each class
846    /// that itself declares the member (not inherited) contributes its name
847    /// token. Kind-specific lookups — a class often declares a property and a
848    /// method with the same short name, and `member_location` can't tell them
849    /// apart.
850    fn member_decl_sites(
851        &self,
852        classes: &[String],
853        symbol: &crate::Name,
854    ) -> Vec<(Arc<str>, crate::Range)> {
855        let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
856        let db = &self.db;
857        for class in classes {
858            let here = crate::db::Fqcn::from_str(db, class);
859            let (loc, needle) = match symbol {
860                crate::Name::Method { name, .. } => {
861                    let Some(m) = crate::db::find_method_in_class(db, here, name) else {
862                        continue;
863                    };
864                    (m.location.clone(), name.to_string())
865                }
866                crate::Name::Property { name, .. } => {
867                    let Some(p) = crate::db::find_property_in_class(db, here, name) else {
868                        continue;
869                    };
870                    (p.location.clone(), name.to_string())
871                }
872                crate::Name::ClassConstant { name, .. } => {
873                    let Some(c) = crate::db::find_class_constant_in_class(db, here, name) else {
874                        continue;
875                    };
876                    (c.location.clone(), name.to_string())
877                }
878                _ => continue,
879            };
880            let Some(loc) = loc else { continue };
881            let range = self.refine_location_to_name(&loc, &needle);
882            out.push((loc.file.clone(), range));
883        }
884        out
885    }
886
887    /// The symbol's declaration site, narrowed to the declared name's own
888    /// token — the lock-free read half of
889    /// [`AnalysisSession::declaration_name_range`]. Symbols the index lacks
890    /// load on demand; no input is written.
891    pub fn declaration_name_range_cached(
892        &self,
893        symbol: &crate::Name,
894    ) -> Result<Option<(Arc<str>, crate::Range)>, Cancelled> {
895        catch(|| self.declaration_name_range(symbol))
896    }
897
898    /// The symbol's declaration site among already-loaded state, narrowed
899    /// from the collector's whole-declaration span to the declared name's
900    /// own token (matching the span shape of recorded references).
901    pub(super) fn declaration_name_range(
902        &self,
903        symbol: &crate::Name,
904    ) -> Option<(Arc<str>, crate::Range)> {
905        if let crate::Name::GlobalConstant(fqn) = symbol {
906            return self.global_constant_decl_range(fqn);
907        }
908        let loc = self.definition_of_unguarded(symbol).ok()?;
909        let short = match symbol {
910            crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
911                crate::db::subtype_index::short_name_of(f)
912            }
913            crate::Name::Method { name, .. }
914            | crate::Name::Property { name, .. }
915            | crate::Name::ClassConstant { name, .. } => name.as_ref(),
916        };
917        // Property declarations carry a `$` sigil in source, but reference
918        // ranges cover the bare name; the word-boundary search below lands on
919        // the name right after the sigil.
920        let range = self.refine_location_to_name(&loc, short);
921        Some((loc.file.clone(), range))
922    }
923
924    /// Narrow a whole-declaration [`mir_types::Location`] to the first
925    /// word-boundary occurrence of `needle` inside its line span. Falls back
926    /// to the location's own coordinates when the text is unavailable or the
927    /// name doesn't appear (e.g. stub-only declarations).
928    fn refine_location_to_name(&self, loc: &mir_types::Location, needle: &str) -> crate::Range {
929        let fallback = span_range(loc.line, loc.col_start as u32, loc.col_end as u32);
930        let Some(text) = self
931            .db
932            .lookup_source_file(loc.file.as_ref())
933            .map(|sf| sf.text(&self.db as &dyn MirDatabase).clone())
934        else {
935            return fallback;
936        };
937        let needle_chars = needle.chars().count() as u32;
938        let first_line = loc.line.saturating_sub(1) as usize;
939        // Exact-case first: PHP property/constant names are case-sensitive
940        // and an early case-insensitive hit can land on an unrelated token
941        // (a type hint sharing the name). Case-insensitive second, for
942        // method/class needles that arrive lowercase-normalized.
943        for case_insensitive in [false, true] {
944            for (idx, line_text) in text.lines().enumerate().skip(first_line) {
945                let line_no = idx as u32 + 1;
946                if line_no > loc.line_end {
947                    break;
948                }
949                let min_col = if line_no == loc.line {
950                    loc.col_start as usize
951                } else {
952                    0
953                };
954                if let Some(col) = identifier_char_col(line_text, needle, min_col, case_insensitive)
955                {
956                    return span_range(line_no, col, col + needle_chars);
957                }
958            }
959        }
960        fallback
961    }
962
963    /// Declaring line of a global constant. Constant slices carry no stored
964    /// location, so this finds the declaring file via the workspace constants
965    /// index and locates the `const NAME` / `define('NAME'` line textually.
966    fn global_constant_decl_site(&self, fqn: &str) -> Option<ConstantDeclSite> {
967        let short = crate::db::subtype_index::short_name_of(fqn);
968        let db = &self.db;
969        let index = crate::db::workspace_index(db);
970        let loc = index.constant_loc(mir_types::Name::from(fqn.trim_start_matches('\\')))?;
971        let file = loc.file().path(db).clone();
972        let sf = db.lookup_source_file(file.as_ref())?;
973        let text = sf.text(db as &dyn MirDatabase);
974        for (idx, line) in text.lines().enumerate() {
975            let trimmed = line.trim_start();
976            let is_decl_line = trimmed.starts_with("const ")
977                || trimmed.contains("define(")
978                || trimmed.contains("define (");
979            if !is_decl_line {
980                continue;
981            }
982            if let Some(name_col) = identifier_char_col(line, short, 0, false) {
983                let stmt_end = match line.find(';') {
984                    Some(byte) => line[..=byte].chars().count(),
985                    None => line.chars().count(),
986                };
987                return Some(ConstantDeclSite {
988                    file,
989                    line: idx as u32 + 1,
990                    stmt_col: (line.chars().count() - trimmed.chars().count()) as u32,
991                    stmt_end: stmt_end as u32,
992                    name_col,
993                    name_len: short.chars().count() as u32,
994                });
995            }
996        }
997        None
998    }
999
1000    fn global_constant_decl_range(&self, fqn: &str) -> Option<(Arc<str>, crate::Range)> {
1001        let site = self.global_constant_decl_site(fqn)?;
1002        let range = span_range(site.line, site.name_col, site.name_col + site.name_len);
1003        Some((site.file, range))
1004    }
1005
1006    /// Whole-statement location of a user global constant's declaration.
1007    fn global_constant_location(&self, fqn: &str) -> Option<mir_types::Location> {
1008        let site = self.global_constant_decl_site(fqn)?;
1009        Some(mir_types::Location {
1010            file: site.file,
1011            line: site.line,
1012            line_end: site.line,
1013            col_start: site.stmt_col as u16,
1014            col_end: site.stmt_end as u16,
1015        })
1016    }
1017
1018    /// Files declaring transitive subclasses of `class_fqn` anywhere in the
1019    /// workspace, excluding `class_fqn`'s own declaring file — the caller
1020    /// adds it. Subclasses are matched by resolved FQCN, so `extends \Ns\Base`
1021    /// and aliased `use` forms are all found. `include_trait_users` also
1022    /// counts files whose classes `use` the trait `class_fqn`.
1023    pub fn subtype_files(
1024        &self,
1025        class_fqn: &str,
1026        include_trait_users: bool,
1027    ) -> Result<Vec<Arc<str>>, Cancelled> {
1028        let files = self.db.source_file_paths();
1029        let mut out: Vec<Arc<str>> = self
1030            .indexed_subtype_classes(class_fqn, &files, include_trait_users)?
1031            .into_iter()
1032            .map(|s| s.file)
1033            .collect();
1034        out.sort();
1035        out.dedup();
1036        Ok(out)
1037    }
1038
1039    /// Transitive subtypes of `class_fqn` (classes/interfaces/enums whose
1040    /// resolved ancestor chain reaches it), answered from the maintained
1041    /// subtype edge index.
1042    ///
1043    /// `files` is the host's candidate scope for the on-demand completeness
1044    /// pass: per BFS round, not-yet-committed files whose text mentions a
1045    /// frontier short name get their definitions committed, so results are
1046    /// complete even before a background sweep has covered the workspace.
1047    /// That short-name gate is only candidate discovery; subtype identity is
1048    /// resolved from the edge index by exact canonical FQCN. Committed files
1049    /// answer from the index with no parsing at all.
1050    ///
1051    /// `include_trait_users` also counts `use Trait;` composition as a
1052    /// subtype edge (visibility-scoping semantics); leave it off for
1053    /// goto-implementation semantics (extends/implements only).
1054    ///
1055    /// Memoized per `(class_fqn, include_trait_users, files, query
1056    /// generation)`, same rationale as [`Self::indexed_references_to`].
1057    pub fn indexed_subtype_classes(
1058        &self,
1059        class_fqn: &str,
1060        files: &[Arc<str>],
1061        include_trait_users: bool,
1062    ) -> Result<Vec<SubtypeClassSite>, Cancelled> {
1063        self.subtype_classes_memoized(class_fqn, files, include_trait_users, false)
1064    }
1065
1066    /// Like [`Self::indexed_subtype_classes`], restricted to subtypes that
1067    /// name `class_fqn` directly in `extends`/`implements` (or `use` when
1068    /// `include_trait_users`), resolved by canonical FQCN.
1069    pub fn indexed_direct_subtype_classes(
1070        &self,
1071        class_fqn: &str,
1072        files: &[Arc<str>],
1073        include_trait_users: bool,
1074    ) -> Result<Vec<SubtypeClassSite>, Cancelled> {
1075        self.subtype_classes_memoized(class_fqn, files, include_trait_users, true)
1076    }
1077
1078    fn subtype_classes_memoized(
1079        &self,
1080        class_fqn: &str,
1081        files: &[Arc<str>],
1082        include_trait_users: bool,
1083        direct_only: bool,
1084    ) -> Result<Vec<SubtypeClassSite>, Cancelled> {
1085        catch(|| {
1086            let key = SubtypeQueryCacheKey {
1087                class_fqn: class_fqn.trim_start_matches('\\').to_ascii_lowercase(),
1088                include_trait_users,
1089                direct_only,
1090                generation: self.query_cache_generation(),
1091                files_hash: hash_files(files),
1092            };
1093            if let Some(hit) = self.index.subtype_queries.get(&key) {
1094                return hit;
1095            }
1096            let out =
1097                self.subtype_classes_uncached(class_fqn, files, include_trait_users, direct_only);
1098            if let Err(cancelled) = self.ensure_no_retire_since(key.generation) {
1099                unwind(cancelled);
1100            }
1101            // See `memoize_references` for why a moved generation skips.
1102            let generation = self.query_cache_generation();
1103            if generation == key.generation {
1104                self.index.subtype_queries.insert(generation, key, &out);
1105            }
1106            out
1107        })
1108    }
1109
1110    fn subtype_classes_uncached(
1111        &self,
1112        class_fqn: &str,
1113        files: &[Arc<str>],
1114        include_trait_users: bool,
1115        direct_only: bool,
1116    ) -> Vec<SubtypeClassSite> {
1117        let db = &self.db;
1118        let mut scanned: HashSet<String> = HashSet::default();
1119        let mut pending: Vec<String> = vec![class_fqn.trim_start_matches('\\').to_string()];
1120        let mut sites: Vec<crate::db::SubtypeSite> = Vec::new();
1121        while !pending.is_empty() {
1122            // Short names only discover stale/uncommitted files worth
1123            // collecting; the result itself comes from the edge index.
1124            let needles: Vec<String> = pending
1125                .drain(..)
1126                .filter(|f| scanned.insert(f.clone()))
1127                .map(|f| crate::db::subtype_index::short_name_of(&f).to_string())
1128                .collect();
1129            if !needles.is_empty() {
1130                self.commit_defs_for_matching(files, &needles);
1131            }
1132            sites = db.subtype_sites_of(class_fqn, include_trait_users, direct_only);
1133            if direct_only {
1134                break;
1135            }
1136            pending = sites
1137                .iter()
1138                .map(|s| s.fqcn.trim_start_matches('\\').to_string())
1139                .filter(|f| !scanned.contains(f))
1140                .collect();
1141        }
1142        let mut out: Vec<SubtypeClassSite> = sites
1143            .into_iter()
1144            .filter_map(|s| {
1145                let loc = s.location.as_ref()?;
1146                let short = crate::db::subtype_index::short_name_of(&s.fqcn).to_string();
1147                let range = self.refine_location_to_name(loc, &short);
1148                Some(SubtypeClassSite {
1149                    fqcn: s.fqcn,
1150                    kind: s.kind,
1151                    is_abstract: s.is_abstract,
1152                    file: s.file,
1153                    range,
1154                })
1155            })
1156            .collect();
1157        // Anonymous classes never reach the definition collector; their
1158        // `new class implements X {}` sites are recorded under the resolved
1159        // canonical parent FQCN during body analysis.
1160        let root_lc = class_fqn.trim_start_matches('\\').to_ascii_lowercase();
1161        let scope: HashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
1162        for (file, line, cs, ce) in db.reference_locations(&format!("impl:{root_lc}")) {
1163            if !scope.contains(file.as_ref()) {
1164                continue;
1165            }
1166            let range = span_range(line, cs as u32, ce as u32);
1167            if out.iter().any(|s| s.file == file && s.range == range) {
1168                continue;
1169            }
1170            out.push(SubtypeClassSite {
1171                fqcn: Arc::from("class@anonymous"),
1172                kind: crate::db::ClassLikeKind::Class,
1173                is_abstract: false,
1174                file,
1175                range,
1176            });
1177        }
1178        out
1179    }
1180
1181    /// Concrete implementations of `class_fqn::method` across its transitive
1182    /// subtypes: the same-named non-abstract method available to each subtype
1183    /// (its own declaration, or one inherited/composed from a parent, trait,
1184    /// or mixin), as `(subtype fqcn, file, name range)`. Subtypes resolving to
1185    /// the same declaring location collapse to a single entry.
1186    #[allow(clippy::type_complexity)]
1187    pub fn indexed_method_implementations(
1188        &self,
1189        class_fqn: &str,
1190        method: &str,
1191        files: &[Arc<str>],
1192    ) -> Result<Vec<(Arc<str>, Arc<str>, crate::Range)>, Cancelled> {
1193        let subs = self.indexed_subtype_classes(class_fqn, files, false)?;
1194        catch(|| {
1195            let db = &self.db;
1196            let mut out: Vec<(Arc<str>, Arc<str>, crate::Range)> = Vec::new();
1197            for sub in &subs {
1198                let here = crate::db::Fqcn::from_str(db, sub.fqcn.as_ref());
1199                let Some((_, m)) = crate::db::find_method_in_chain(db, here, method) else {
1200                    continue;
1201                };
1202                if m.is_abstract {
1203                    continue;
1204                }
1205                let Some(loc) = m.location.as_ref() else {
1206                    continue;
1207                };
1208                let range = self.refine_location_to_name(loc, method);
1209                out.push((sub.fqcn.clone(), loc.file.clone(), range));
1210            }
1211            out.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.start.line.cmp(&b.2.start.line)));
1212            out.dedup_by(|a, b| a.1 == b.1 && a.2 == b.2);
1213            out
1214        })
1215    }
1216
1217    /// Commit definitions (class edges + freshness) for every file in `files`
1218    /// that is stale (committed against older text) or that has never been
1219    /// committed and mentions one of `shorts` as a whole identifier.
1220    ///
1221    /// The textual gate answers from the shared per-file mention cache — the
1222    /// same one [`Self::indexed_references_to`]'s gate populates — so a file
1223    /// scanned by either consumer answers the other with a set lookup. A file
1224    /// the cache can't answer for is scanned once against the whole name
1225    /// universe and recorded.
1226    ///
1227    /// Unwinds with [`Cancelled`] when a file was retired after this
1228    /// snapshot, once every other file is committed.
1229    fn commit_defs_for_matching(&self, files: &[Arc<str>], shorts: &[String]) {
1230        use rayon::prelude::*;
1231
1232        // Admit the frontier names before preparing, so every needle gets a
1233        // real query (a declared class's short name is already in the
1234        // universe from indexing — admission then changes nothing).
1235        self.db
1236            .add_literal_mention_names(shorts.iter().map(|s| s.as_str()));
1237        let queries: Vec<_> = shorts
1238            .iter()
1239            .filter_map(|s| self.db.prepare_class_mention_query(s))
1240            .collect();
1241        let mention_scanner = self.db.class_mention_scanner();
1242        // Admission guarantees a query per needle and a non-empty universe;
1243        // anything else is defensive — the gate then admits every candidate
1244        // (recommit rather than skip, the conservative direction).
1245        let use_mentions = queries.len() == shorts.len() && mention_scanner.is_some();
1246        type Work = (Arc<str>, Arc<str>, Vec<crate::db::SubtypeEntry>);
1247        let index = &*self.index;
1248        let results: Vec<(Option<Work>, Option<MentionScanRecord>)> = files
1249            .par_iter()
1250            .map_with(self.db.clone(), |db, path| {
1251                let Some(sf) = db.lookup_source_file(path.as_ref()) else {
1252                    return (None, None);
1253                };
1254                let text = sf.text(&*db as &dyn MirDatabase).clone();
1255                let freshness = index.defs_freshness(sf, &text);
1256                if freshness == DefsFreshness::Fresh {
1257                    return (None, None);
1258                }
1259                // Never-committed files must mention a frontier name; stale
1260                // (previously committed) files recommit unconditionally —
1261                // their classes may have re-parented.
1262                let mut scan_rec: Option<MentionScanRecord> = None;
1263                if use_mentions && freshness == DefsFreshness::Uncommitted {
1264                    let mut answer = Some(false);
1265                    for q in &queries {
1266                        match db.class_mention_answer(path.as_ref(), q, &text) {
1267                            Some(true) => {
1268                                answer = Some(true);
1269                                break;
1270                            }
1271                            Some(false) => {}
1272                            None => answer = None,
1273                        }
1274                    }
1275                    let hit = match answer {
1276                        Some(hit) => hit,
1277                        None => {
1278                            // Uncoverable entry: one scan answers every
1279                            // query and is recorded below.
1280                            let scanner = mention_scanner.as_ref().unwrap();
1281                            let names = scanner.scan(&text);
1282                            let hit = queries.iter().any(|q| names.binary_search(&q.name).is_ok());
1283                            scan_rec = Some((path.clone(), text.clone(), names));
1284                            hit
1285                        }
1286                    };
1287                    if !hit {
1288                        return (None, scan_rec);
1289                    }
1290                }
1291                let defs = crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
1292                let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
1293                (Some((path.clone(), text, entries)), scan_rec)
1294            })
1295            .collect();
1296        // Record the fallback scans regardless of hit/miss: each is a
1297        // complete, current mention set for its file, so the next round's
1298        // (and the references gate's) checks become set lookups.
1299        let mut refused = Ok(());
1300        for (work, scan_rec) in results {
1301            if let (Some(scanner), Some((file, text, names))) = (&mention_scanner, scan_rec) {
1302                self.db
1303                    .set_file_class_mentions(&file, &text, scanner.epoch(), names);
1304            }
1305            if let Some((file, text, entries)) = work {
1306                if let Err(cancelled) = self.index.commit_class_edges(
1307                    &self.db,
1308                    &file,
1309                    &text,
1310                    entries,
1311                    self.retire_epoch,
1312                ) {
1313                    refused = Err(cancelled);
1314                }
1315            }
1316        }
1317        if let Err(cancelled) = refused {
1318            unwind(cancelled);
1319        }
1320    }
1321
1322    /// Class-level issues (inheritance violations, abstract-method gaps,
1323    /// override incompatibilities) for `files`.
1324    ///
1325    /// These checks are cross-file by nature and are not emitted by
1326    /// [`Self::analyze`]. Circular-inheritance checks always run against the
1327    /// full workspace graph regardless of the `files` filter — a cycle is a
1328    /// workspace-wide problem.
1329    pub fn class_issues(&self, files: &[Arc<str>]) -> Result<Vec<crate::Issue>, Cancelled> {
1330        catch(|| {
1331            let db = &self.db;
1332            let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
1333            let file_data: Vec<(Arc<str>, Arc<str>)> = files
1334                .iter()
1335                .filter_map(|f| {
1336                    let sf = db.lookup_source_file(f)?;
1337                    Some((f.clone(), sf.text(db as &dyn MirDatabase).clone()))
1338                })
1339                .collect();
1340            crate::class::ClassAnalyzer::with_files(db, file_set, &file_data).analyze_all()
1341        })
1342    }
1343
1344    /// Collector-phase issues (e.g. `BackedEnumCaseTypeMismatch`,
1345    /// `InvalidReadonlyPropertyDeclaration`, `InvalidDocblock`, and raw parse
1346    /// errors) for `files`.
1347    ///
1348    /// These are found while building a file's declaration slice, before body
1349    /// analysis or cross-file class checks run — neither [`Self::analyze`]
1350    /// nor [`Self::class_issues`] reports them, so a caller merging just those
1351    /// two sources silently drops every collector-time diagnostic.
1352    pub fn collector_issues(&self, files: &[Arc<str>]) -> Result<Vec<crate::Issue>, Cancelled> {
1353        catch(|| {
1354            files
1355                .iter()
1356                .filter_map(|f| self.db.lookup_source_file(f))
1357                .flat_map(|sf| {
1358                    crate::db::collect_file_definitions(&self.db, sf)
1359                        .issues
1360                        .as_ref()
1361                        .clone()
1362                })
1363                .collect()
1364        })
1365    }
1366
1367    /// Admission predicate for [`Self::stale_reference_candidates_unguarded`]; see
1368    /// [`ReferenceGate`] and `reference_gate_needles`.
1369    ///
1370    /// Instance/static methods other than `__construct`/`__invoke` on a
1371    /// resolvable owner gate on the member name alone: every call site
1372    /// spells it (`->name(`, `::name(`), while the owner's short name would
1373    /// admit every file mentioning the class for any reason.
1374    /// `__construct` gates on the owner's short name plus the raw call tokens
1375    /// `->__construct` / `::__construct`. `__invoke` keeps the general
1376    /// owner/name gate because `$obj()` call sites do not spell `__invoke`.
1377    pub(super) fn reference_gate(&self, symbol: &crate::Name) -> ReferenceGate {
1378        ReferenceGate::for_symbol(&self.db, symbol)
1379    }
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384    use super::*;
1385    use crate::{AnalysisSession, IndexCancel, Name, ReferenceIncludes};
1386    use salsa::Database as _;
1387
1388    const BASE: &str =
1389        "<?php\nclass Base { public function run(): void {} public function stop(): void {} }\n";
1390    const CALLS_RUN: &str = "<?php\nfunction go(Base $b): void { $b->run(); }\n";
1391    const CALLS_STOP: &str = "<?php\nfunction go(Base $b): void { $b->stop(); }\n";
1392
1393    /// Pins the warm pass's commit landing after an owner text write has
1394    /// started but before it applies: the commit must not leave the file
1395    /// fresh for the incoming text.
1396    #[test]
1397    fn warm_commit_racing_a_text_write_leaves_the_file_stale() {
1398        let (mut session, files) = prepared_workspace(CALLS_RUN);
1399        let snap = session.snapshot();
1400        let mut pass = snap
1401            .stage_warm(&files[1..], &IndexCancel::new())
1402            .unwrap()
1403            .unwrap();
1404
1405        let caller = files[1].clone();
1406        let writer = std::thread::spawn(move || {
1407            session.upsert_source_file(caller, Arc::from(CALLS_STOP), salsa::Durability::LOW);
1408            session
1409        });
1410        await_pending_write(&snap);
1411        snap.commit_warm(&mut pass).unwrap();
1412        drop(snap);
1413        let mut session = writer.join().unwrap();
1414
1415        assert_eq!(callers_of(&mut session, &files, "stop"), [files[1].clone()]);
1416        assert!(callers_of(&mut session, &files, "run").is_empty());
1417    }
1418
1419    #[test]
1420    fn warm_commit_after_an_ingest_retired_the_file_is_refused() {
1421        let (session, files) = prepared_workspace(CALLS_RUN);
1422        let snap = session.snapshot();
1423        let mut pass = snap
1424            .stage_warm(&files[1..], &IndexCancel::new())
1425            .unwrap()
1426            .unwrap();
1427
1428        let caller = files[1].clone();
1429        let writer = std::thread::spawn(move || {
1430            let mut session = session;
1431            session.ingest_file(caller, Arc::from(CALLS_STOP));
1432            session
1433        });
1434        await_pending_write(&snap);
1435        assert!(snap.commit_warm(&mut pass).is_err());
1436        drop(snap);
1437        let mut session = writer.join().unwrap();
1438
1439        assert!(callers_of(&mut session, &files, "run").is_empty());
1440        assert_eq!(callers_of(&mut session, &files, "stop"), [files[1].clone()]);
1441    }
1442
1443    #[test]
1444    fn commits_after_invalidate_file_retired_the_file_are_refused() {
1445        const CHILD_CALLS_RUN: &str =
1446            "<?php\nclass Child extends Base {}\nfunction go(Base $b): void { $b->run(); }\n";
1447        let (session, files) = prepared_workspace(CHILD_CALLS_RUN);
1448        let snap = session.snapshot();
1449        let mut pass = snap
1450            .stage_warm(&files[1..], &IndexCancel::new())
1451            .unwrap()
1452            .unwrap();
1453        let parsed = php_rs_parser::parse(CHILD_CALLS_RUN);
1454
1455        let caller = files[1].clone();
1456        let writer = std::thread::spawn(move || {
1457            let mut session = session;
1458            session.invalidate_file(&caller);
1459            session
1460        });
1461        await_pending_write(&snap);
1462        assert!(snap.commit_warm(&mut pass).is_err());
1463        assert!(snap
1464            .analyze(
1465                files[1].clone(),
1466                CHILD_CALLS_RUN,
1467                &parsed.program,
1468                &parsed.source_map
1469            )
1470            .is_err());
1471        drop(snap);
1472        let mut session = writer.join().unwrap();
1473
1474        assert!(callers_of(&mut session, &files, "run").is_empty());
1475        assert!(session
1476            .indexed_subtype_classes("Base", &files, false)
1477            .is_empty());
1478    }
1479
1480    #[test]
1481    fn cancelled_reference_commit_reports_cancellation() {
1482        let (mut session, files) = prepared_workspace(CALLS_RUN);
1483        let snap = session.snapshot();
1484        let stale = snap.stale_reference_candidates_unguarded(&Name::method("Base", "run"), &files);
1485        assert!(stale.contains(&files[1]));
1486
1487        let caller = files[1].clone();
1488        let writer = std::thread::spawn(move || {
1489            session.upsert_source_file(caller, Arc::from(CALLS_STOP), salsa::Durability::LOW);
1490            session
1491        });
1492        await_pending_write(&snap);
1493        assert!(snap.commit_reference_candidates(&stale).is_err());
1494        drop(snap);
1495        writer.join().unwrap();
1496    }
1497
1498    /// Top-level code only: ingesting it defines no symbols, so the ingest
1499    /// itself writes nothing to salsa.
1500    const ROUTES_CALLS_RUN: &str = "<?php\n(new Base())->run();\n";
1501
1502    /// A workspace whose `routes.php` is registered and committed by a
1503    /// references query but was never passed through `ingest_file` — the
1504    /// state of a file an editor opens after the initial scan.
1505    fn registered_routes_workspace() -> (AnalysisSession, Vec<Arc<str>>) {
1506        let mut session = AnalysisSession::new(PhpVersion::LATEST);
1507        let files: Vec<Arc<str>> = vec![Arc::from("base.php"), Arc::from("routes.php")];
1508        session.ingest_file(files[0].clone(), Arc::from(BASE));
1509        session.upsert_source_file(
1510            files[1].clone(),
1511            Arc::from(ROUTES_CALLS_RUN),
1512            salsa::Durability::LOW,
1513        );
1514        session.prepare_for_query(None);
1515        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1516        (session, files)
1517    }
1518
1519    /// Opening the file lands between a query's freshness pass and its
1520    /// posting read. Nothing cancels the query, so it must still see the
1521    /// file's postings — and must not memoize a result without them.
1522    #[test]
1523    fn ingest_of_unchanged_text_mid_query_keeps_references() {
1524        let (mut session, files) = registered_routes_workspace();
1525        let symbol = Name::method("Base", "run");
1526        let revision = session.text_revision();
1527
1528        let snap = session.snapshot();
1529        let key = snap.reference_query_key(&symbol, &files, false, ReferenceIncludes::Plain);
1530        assert!(snap
1531            .stale_reference_candidates_unguarded(&symbol, &files)
1532            .is_empty());
1533        session.ingest_file(files[1].clone(), Arc::from(ROUTES_CALLS_RUN));
1534        let out = snap.read_references(&symbol, &files, false, ReferenceIncludes::Plain);
1535        assert!(snap.ensure_no_retire_since(key.generation).is_ok());
1536        snap.memoize_references(key, &out);
1537        drop(snap);
1538
1539        assert_eq!(session.text_revision(), revision, "ingest wrote salsa");
1540        assert_eq!(
1541            out.into_iter().map(|(file, _)| file).collect::<Vec<_>>(),
1542            [files[1].clone()]
1543        );
1544        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1545    }
1546
1547    /// Any retire without a salsa write (e.g. `re_analyze_file` on unchanged
1548    /// text, between its clear and re-record) must cancel a query that read
1549    /// the cleared postings and keep its result out of the memo.
1550    #[test]
1551    fn query_spanning_a_retirement_is_cancelled_and_not_memoized() {
1552        let (mut session, files) = registered_routes_workspace();
1553        let symbol = Name::method("Base", "run");
1554
1555        let snap = session.snapshot();
1556        let key = snap.reference_query_key(&symbol, &files, false, ReferenceIncludes::Plain);
1557        assert!(snap
1558            .stale_reference_candidates_unguarded(&symbol, &files)
1559            .is_empty());
1560        session
1561            .index
1562            .retire_references(&session.db.salsa, files[1].as_ref());
1563        let out = snap.read_references(&symbol, &files, false, ReferenceIncludes::Plain);
1564        assert!(out.is_empty());
1565        assert!(snap.ensure_no_retire_since(key.generation).is_err());
1566        snap.memoize_references(key, &out);
1567        drop(snap);
1568
1569        let hits = session.ref_query_cache_hits();
1570        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1571        assert_eq!(session.ref_query_cache_hits(), hits);
1572    }
1573
1574    /// The cache-hit fast path replaces postings without a salsa write, so
1575    /// it must retire like the analysis path: a query spanning it cancels.
1576    #[test]
1577    fn re_analyze_file_cache_hit_cancels_spanning_queries() {
1578        let dir = tempfile::tempdir().unwrap();
1579        let mut session = AnalysisSession::new(PhpVersion::LATEST).with_cache_dir(dir.path());
1580        let files: Vec<Arc<str>> = vec![Arc::from("base.php"), Arc::from("caller.php")];
1581        let opts = crate::BatchOptions::new();
1582        session.ingest_file(files[0].clone(), Arc::from(BASE));
1583        session.re_analyze_file(&files[1], CALLS_RUN, &opts);
1584        session.prepare_for_query(None);
1585        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1586
1587        let symbol = Name::method("Base", "run");
1588        let snap = session.snapshot();
1589        let key = snap.reference_query_key(&symbol, &files, false, ReferenceIncludes::Plain);
1590        let revision = session.text_revision();
1591        session.re_analyze_file(&files[1], CALLS_RUN, &opts);
1592        assert_eq!(session.text_revision(), revision, "took the analysis path");
1593        assert!(snap.ensure_no_retire_since(key.generation).is_err());
1594        drop(snap);
1595
1596        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1597    }
1598
1599    fn prepared_workspace(caller_text: &str) -> (AnalysisSession, Vec<Arc<str>>) {
1600        let mut session = AnalysisSession::new(PhpVersion::LATEST);
1601        let files: Vec<Arc<str>> = vec![Arc::from("base.php"), Arc::from("caller.php")];
1602        session.ingest_file(files[0].clone(), Arc::from(BASE));
1603        session.ingest_file(files[1].clone(), Arc::from(caller_text));
1604        session.prepare_for_query(Some(&files[1]));
1605        (session, files)
1606    }
1607
1608    /// Spins until an owner write has tripped cancellation; the writer then
1609    /// waits for `snap` to drop before applying.
1610    fn await_pending_write(snap: &AnalysisSnapshot) {
1611        while catch(|| snap.db.unwind_if_revision_cancelled()).is_ok() {
1612            std::thread::yield_now();
1613        }
1614    }
1615
1616    fn callers_of(
1617        session: &mut AnalysisSession,
1618        files: &[Arc<str>],
1619        method: &str,
1620    ) -> Vec<Arc<str>> {
1621        session
1622            .indexed_references_to(
1623                &Name::method("Base", method),
1624                files,
1625                false,
1626                ReferenceIncludes::Plain,
1627                &|| false,
1628            )
1629            .unwrap()
1630            .into_iter()
1631            .map(|(file, _)| file)
1632            .collect()
1633    }
1634
1635    /// Everything a whole-file analysis leaves behind that a later one could
1636    /// observe: loaded dependencies, the warm-up skip mark, and the result.
1637    #[derive(Debug, PartialEq)]
1638    struct AnalysisOutcome {
1639        issues: Vec<String>,
1640        symbols: usize,
1641        vendor_class_loaded: bool,
1642        vendor_function_loaded: bool,
1643        eager_files_drained: bool,
1644        stubs_loaded: usize,
1645        prepared: bool,
1646        prepare_generation: u64,
1647        references: Vec<String>,
1648    }
1649
1650    const DEPENDENT: &str = "<?php\nnamespace App;\nfunction go(\\Vendor\\Base $b): void { vendor_helper('x'); new \\ArrayObject(); }\n";
1651
1652    fn composer_project(root: &std::path::Path) -> AnalysisSession {
1653        let vendor = root.join("vendor");
1654        std::fs::create_dir_all(vendor.join("composer")).unwrap();
1655        std::fs::create_dir_all(vendor.join("helpers")).unwrap();
1656        std::fs::create_dir_all(root.join("lib")).unwrap();
1657        std::fs::write(
1658            vendor.join("composer/autoload_files.php"),
1659            "<?php\n$vendorDir = dirname(__DIR__);\n$baseDir = dirname($vendorDir);\nreturn array(\n    'abc123' => $vendorDir . '/helpers/functions.php',\n);\n",
1660        )
1661        .unwrap();
1662        for empty in ["psr4", "classmap", "namespaces"] {
1663            std::fs::write(
1664                vendor.join(format!("composer/autoload_{empty}.php")),
1665                "<?php\nreturn [];\n",
1666            )
1667            .unwrap();
1668        }
1669        std::fs::write(
1670            vendor.join("helpers/functions.php"),
1671            "<?php\nfunction vendor_helper(string $s): string { return $s; }\n",
1672        )
1673        .unwrap();
1674        std::fs::write(
1675            root.join("lib/Base.php"),
1676            "<?php\nnamespace Vendor;\nclass Base {}\n",
1677        )
1678        .unwrap();
1679        std::fs::write(
1680            root.join("composer.json"),
1681            r#"{"autoload":{"psr-4":{"App\\":"src/","Vendor\\":"lib/"}}}"#,
1682        )
1683        .unwrap();
1684        let psr4 = crate::composer::Psr4Map::from_composer(root).unwrap();
1685        AnalysisSession::new(PhpVersion::LATEST).with_psr4(Arc::new(psr4))
1686    }
1687
1688    fn outcome(
1689        session: &AnalysisSession,
1690        file: &Arc<str>,
1691        analysis: crate::FileAnalysis,
1692    ) -> AnalysisOutcome {
1693        let text = session.source_of(file).unwrap();
1694        AnalysisOutcome {
1695            issues: analysis
1696                .issues
1697                .iter()
1698                .map(|i| format!("{:?}", i.kind))
1699                .collect(),
1700            symbols: analysis.symbols.len(),
1701            vendor_class_loaded: {
1702                let view = session.db_view();
1703                let db = view.db();
1704                crate::db::class_like_indexed(db, crate::db::Fqcn::from_str(db, "Vendor\\Base"))
1705            },
1706            vendor_function_loaded: session.contains_function("vendor_helper"),
1707            eager_files_drained: session.pending_eager_function_files.is_none(),
1708            stubs_loaded: session.loaded_stub_count(),
1709            prepared: session.is_prepared_for_analysis(
1710                file,
1711                &text,
1712                session.prepare_generation_snapshot(),
1713            ),
1714            prepare_generation: session.prepare_generation_snapshot(),
1715            references: ["cls:Vendor\\Base", "fn:vendor_helper", "cls:ArrayObject"]
1716                .into_iter()
1717                .map(|key| format!("{key}={:?}", session.reference_locations(key)))
1718                .collect(),
1719        }
1720    }
1721
1722    /// A host that ingests + `prepare_for_query(Some(file))` under its lock and
1723    /// then runs `AnalysisSnapshot::analyze` leaves the session as
1724    /// `FileAnalyzer::analyze` does, including the warm-up skip mark.
1725    #[test]
1726    fn prepare_for_query_then_snapshot_analyze_matches_file_analyzer() {
1727        let file: Arc<str> = Arc::from("consumer.php");
1728        let parsed = php_rs_parser::parse(DEPENDENT);
1729
1730        let root = tempfile::tempdir().unwrap();
1731        let mut via_analyzer = composer_project(root.path());
1732        via_analyzer.ingest_file(file.clone(), Arc::from(DEPENDENT));
1733        let analysis = crate::FileAnalyzer::new(&mut via_analyzer).analyze(
1734            file.clone(),
1735            DEPENDENT,
1736            &parsed.program,
1737            &parsed.source_map,
1738        );
1739        let expected = outcome(&via_analyzer, &file, analysis);
1740
1741        let root = tempfile::tempdir().unwrap();
1742        let mut via_snapshot = composer_project(root.path());
1743        via_snapshot.ingest_file(file.clone(), Arc::from(DEPENDENT));
1744        via_snapshot.prepare_for_query(Some(&file));
1745        let analysis = via_snapshot
1746            .snapshot()
1747            .analyze(file.clone(), DEPENDENT, &parsed.program, &parsed.source_map)
1748            .unwrap();
1749        let actual = outcome(&via_snapshot, &file, analysis);
1750
1751        assert!(
1752            expected.prepared && expected.vendor_function_loaded,
1753            "{expected:#?}"
1754        );
1755        assert_eq!(actual, expected);
1756    }
1757
1758    /// `FileAnalyzer::analyze` warms up from the AST it is handed, so it works
1759    /// on a file never ingested; `prepare_for_query` finds no input to warm
1760    /// from, so the host must ingest before the snapshot analysis.
1761    #[test]
1762    fn prepare_for_query_warms_nothing_for_a_file_that_was_never_ingested() {
1763        let file: Arc<str> = Arc::from("consumer.php");
1764        let parsed = php_rs_parser::parse(DEPENDENT);
1765        let unresolved = |analysis: &crate::FileAnalysis| {
1766            analysis
1767                .issues
1768                .iter()
1769                .any(|i| matches!(i.kind, crate::IssueKind::UndefinedFunction { .. }))
1770        };
1771
1772        let root = tempfile::tempdir().unwrap();
1773        let mut via_analyzer = composer_project(root.path());
1774        let analysis = crate::FileAnalyzer::new(&mut via_analyzer).analyze(
1775            file.clone(),
1776            DEPENDENT,
1777            &parsed.program,
1778            &parsed.source_map,
1779        );
1780        assert!(!unresolved(&analysis), "{:?}", analysis.issues);
1781
1782        let root = tempfile::tempdir().unwrap();
1783        let mut via_snapshot = composer_project(root.path());
1784        via_snapshot.prepare_for_query(Some(&file));
1785        let analysis = via_snapshot
1786            .snapshot()
1787            .analyze(file, DEPENDENT, &parsed.program, &parsed.source_map)
1788            .unwrap();
1789        assert!(unresolved(&analysis), "{:?}", analysis.issues);
1790    }
1791}