Skip to main content

lanekeep_types/
builtin.rs

1//! The bounded provider: this crate's own oracle, plus the files it is allowed to open.
2//!
3//! Run-scoped state lives here rather than on [`TypeScriptOracle`](crate::TypeScriptOracle),
4//! which owns exactly one parse and must stay that way. What this holds is a parser and a
5//! cache of parsed declaration files, keyed by content hash — so a library's `.d.ts` is
6//! parsed once per version of its bytes, not once per run: a provider a session holds across
7//! requests (#191) keeps every entry whose file has not moved, and
8//! [`TypeProvider::begin_run`] no longer throws that cache away. [`TypeProvider::revalidate`]
9//! is what drops a hash-mismatched entry proactively, ahead of a query finding out the hard
10//! way.
11//!
12//! # Locks
13//!
14//! Poisoning is treated as "take the value anyway", the posture
15//! `lanekeep_core::files::FileAccess` documents on its own memo: nothing under these locks can
16//! panic, and refusing to answer because an unrelated worker died would turn a rule's question
17//! into a failure with nothing to do with it.
18//!
19//! The parser's lock is the one that is genuinely *held across work* — a whole parse, which
20//! for a large `typescript.d.ts` is tens of milliseconds — and it is a plain mutex rather than
21//! an entry API. So two workers that reach an uncached declaration file at the same moment
22//! both parse it and the second write wins. That is benign: the parses are of the same bytes
23//! and produce the same answers, the cost is at most one extra parse per worker that races,
24//! and the alternative is holding a lock across the filesystem read as well.
25
26use std::cell::Cell;
27use std::collections::{BTreeMap, BTreeSet};
28use std::fmt;
29use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
30
31use lanekeep_core::{AnalysisBudget, FileAccess, FilePath};
32use lanekeep_lang::Language;
33use lanekeep_lang::binding::{Binding, ImportedName};
34
35use crate::declarations::{
36    Declaration, ExportTarget, Exported, declared_in, declared_name, find_export,
37    imports_with_names, target_node,
38};
39use crate::oracle::{
40    Followed, ImportResolution, MAX_DEPTH, TypeScriptOracle, TypeScriptSupport, annotation_child,
41    declaration_body, member_annotation, member_name, optional_access, sole_non_nullish_arm,
42    type_contains_nullish, type_name_node, with_optional,
43};
44use crate::provider::{BeginRunError, Query, TypeProvider};
45use crate::resolve::resolve_specifier;
46use crate::types::{Symbol, Type};
47
48/// How far a chain of re-exports is followed.
49///
50/// The same figure the oracle's own recursion bound uses, for the same reason: exceeding it
51/// is indistinguishable from not knowing, which is already a first-class answer. Fixed rather
52/// than measured — a bound that depended on elapsed time would put the clock in the cache key.
53const MAX_EXPORT_DEPTH: u32 = 16;
54
55/// A sink handed a resolved member container and the file it lives in, by
56/// [`BuiltinProvider::with_container`].
57///
58/// A `type` alias rather than the bare `dyn FnMut` written inline: it appears in two
59/// signatures and trips `clippy::type_complexity` at each. The `for<'a>` is the point — the
60/// container node and its file borrow a declaration parse opened *inside* `with_container`,
61/// whose lifetime the caller cannot name, so the sink must accept any.
62type ContainerSink<'f> = dyn for<'a> FnMut(
63        (&'a FilePath, &'a tree_sitter::Tree, &'a str),
64        tree_sitter::Node<'a>,
65    ) -> Option<Type>
66    + 'f;
67
68/// The provider that reads declaration files with this crate's own oracle.
69pub struct BuiltinProvider {
70    support: TypeScriptSupport,
71    /// The main grammar's shape digest — [`lanekeep_lang::grammar_digest`], its node kinds
72    /// and fields — held from probe time beside the resolver's own analysis identity. Both
73    /// are what [`TypeProvider::identity`] folds, with the tsx grammar's digest behind a
74    /// presence byte, so *which* grammar parses `.ts` and which parses `.tsx` are both in the
75    /// key. `TypeScript` and `Tsx` share one analysis identity, and a fold over that alone
76    /// let a provider over the TSX grammar warm the cache of one over TypeScript.
77    grammar_digest: [u8; 32],
78    /// The resolver's analysis identity, from the language that was probed.
79    analysis_identity: [u8; 32],
80    /// One parser per grammar this provider opens, behind a lock — this one for every path
81    /// that is not `.tsx`, the second grammar's (when one was given at probe time) for the
82    /// rest, chosen by the resolved path's extension in [`Self::parser_for`].
83    ///
84    /// **It does parse corpus files a second time**, and an earlier version of this comment
85    /// claimed the opposite. `RELATIVE_SUFFIXES` prefers `.ts` over `.d.ts`, so a relative
86    /// import of a project source — `import { parsed } from '../lib/ids'` — resolves to the
87    /// very file the engine parses itself, and this parses it again into its own arena. Once
88    /// per run per file, not once per importer, so the cost is bounded by the number of
89    /// distinct files reached through imports rather than by the number of imports.
90    ///
91    /// Sharing the engine's node arena would remove it and is a separate seam: the arena is
92    /// keyed by the run's file list, and a declaration file under `node_modules` is not in it
93    /// at all, so the two would have to meet somewhere neither owns today.
94    ///
95    /// This file is on `local/one-parser-per-file`'s `allow` list in `lanekeep.json` for
96    /// exactly that reason, and this paragraph is the rationale the list cannot carry — JSON
97    /// has no comments. The rule is right about what it sees; the second parser is deliberate,
98    /// and the entry is what says a reviewer has already weighed it.
99    parser: Mutex<tree_sitter::Parser>,
100    /// The second grammar's parser, behind its **own** lock — never this one's — together
101    /// with the identity of the language probed to build it.
102    ///
103    /// `None` when no second grammar was given. The resolver still reaches a `.tsx` sibling
104    /// then, but the main grammar reads its JSX as `ERROR` nodes, and `complete()` counts
105    /// those as unread: an honest "incomplete" rather than a confidently wrong answer.
106    tsx: Option<TsxParser>,
107    /// Declaration files parsed so far, by path — kept across `begin_run`, not cleared by it.
108    ///
109    /// A `BTreeMap`, per the ordering invariant, and behind a lock because rayon runs one
110    /// worker per file and they share this provider. A library's `.d.ts` is parsed once per
111    /// version of its bytes, which is the difference between a 500 KB `typescript.d.ts`
112    /// costing tens of milliseconds once and costing them per importing file — and, for a
113    /// provider a session holds across requests (#191), the difference between costing them
114    /// once per session and once per request.
115    ///
116    /// Entries carry the hash their bytes had, and [`Self::declaration`] compares it against
117    /// what the *asking* access read — see that method for why serving by path alone writes an
118    /// entry describing neither version of a file rewritten mid-run. That same hash check is
119    /// what makes it safe for [`TypeProvider::begin_run`] to leave this memo alone: a stale
120    /// entry is never served, so nothing here needs a cold start. [`TypeProvider::revalidate`]
121    /// drops a mismatched entry ahead of time, so a held provider is not carrying a parse
122    /// tree for a version of a file it will never answer about again.
123    declarations: Mutex<BTreeMap<FilePath, Arc<Declaration>>>,
124    /// Whether each file's imports all resolved, decided once per file.
125    ///
126    /// The pass behind it is eager — every import is resolved, not only the ones a rule asked
127    /// about — which is what records an absent declaration as a dependency even when nothing
128    /// went looking for the type behind it.
129    ///
130    /// **Also keyed by path alone, and this is the memo where that bites hardest.**
131    /// [`Self::complete`] answers from it before any `resolve_specifier` runs, so a second
132    /// request served from a stale entry records *no import dependencies at all* — a cache
133    /// entry with nothing in it to invalidate. A provider held across runs must clear this
134    /// one rather than drop by hash: a `bool` has no hash to drop by.
135    completeness: Mutex<BTreeMap<FilePath, bool>>,
136    /// How many times [`Self::declaration`] has actually parsed a file, this process.
137    ///
138    /// Test-only: the seam that lets a pin distinguish "answered from the memo" from
139    /// "parsed again" without inferring it from timing, which would flake on a loaded
140    /// machine. Nothing outside `#[cfg(test)]` reads it, so it costs nothing in a real run.
141    #[cfg(test)]
142    parses: std::sync::atomic::AtomicUsize,
143}
144
145impl fmt::Debug for BuiltinProvider {
146    /// Hand-written because neither `TypeScriptSupport` nor `tree_sitter::Parser` is
147    /// `Debug`, the same reason and the same shape as the oracle's own impl.
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        f.debug_struct("BuiltinProvider").finish_non_exhaustive()
150    }
151}
152
153/// The second parser: the grammar for the `.tsx` files the resolver reaches, behind its own
154/// lock so a `.ts` parse and a `.tsx` parse never wait on each other.
155struct TsxParser {
156    parser: Mutex<tree_sitter::Parser>,
157    /// The probed grammar's shape digest, folded into [`TypeProvider::identity`] so the
158    /// grammar a `.tsx` answer was read with is part of the cache key that answer lands
159    /// under — the grammar's own, not the analysis identity every language in the family
160    /// shares.
161    grammar_digest: [u8; 32],
162}
163
164impl TsxParser {
165    /// A parser over the given grammar, or `None` when the grammar will not load — the same
166    /// refusal the main probe makes, for the same reason: a parser that cannot be built is
167    /// a provider that cannot read what the resolver hands it.
168    fn probe(language: &dyn Language) -> Option<Self> {
169        let mut parser = tree_sitter::Parser::new();
170        parser.set_language(&language.grammar()).ok()?;
171        Some(Self {
172            parser: Mutex::new(parser),
173            grammar_digest: lanekeep_lang::grammar_digest(&language.grammar()),
174        })
175    }
176}
177
178/// Whether a project-relative path names a `.tsx` file.
179///
180/// Case-insensitive because the filesystem decides case, and the parse has to agree with the
181/// resolver's suffix probe — and with `LanguageRegistry::for_path`, which lowercases too — on
182/// whatever case the tree spells. The stem check keeps a hidden `.tsx` — no stem at all,
183/// at the root or in any directory — from counting as one.
184fn extension_is_tsx(path: &str) -> bool {
185    match path.rsplit_once('.') {
186        Some((stem, extension)) => {
187            !stem.is_empty() && !stem.ends_with('/') && extension.eq_ignore_ascii_case("tsx")
188        }
189        None => false,
190    }
191}
192
193/// Why an export walk did not end at a declaration.
194///
195/// `complete()` tells the two apart and nothing else does: [`BuiltinProvider::export_target`]
196/// folds both to `None`, because a rule can do nothing different with either.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198enum Unreached {
199    /// A link could not be read: a specifier that resolves to nothing, a file that will not
200    /// parse, a declaration the parser did not finish, or a chain past `MAX_EXPORT_DEPTH`.
201    Unread,
202    /// Every link was read and none declares the name in a shape this walk models — a
203    /// namespace binding, or a module whose members reach the importer some way the walk
204    /// does not follow, `export = X` beside `declare namespace X` above all.
205    Unmodeled,
206}
207
208/// One [`BuiltinProvider::is_assignable_to`] call's bookkeeping.
209///
210/// Three fields rather than three parameters, so [`BuiltinProvider::assignable`] and
211/// [`BuiltinProvider::heritage_assignable`] keep the argument count `clippy::too_many_arguments`
212/// allows — the same reason their `at` triple is bundled.
213struct Walk {
214    /// Declarations on the *current path*, each with its position on that path.
215    ///
216    /// Path-scoped rather than seen-once: a sibling branch that reaches the same ancestor
217    /// through a different path must be answered rather than told it was already walked. Only
218    /// a cycle on the current path is meant to be cut.
219    ///
220    /// The index is what makes [`Walk::lowlink`] work: a cycle is a back-edge to a position on
221    /// the current path, and how far back it reaches is what decides which declarations above
222    /// it may still memoize.
223    visiting: BTreeMap<(FilePath, String), usize>,
224    /// What each declaration answered, for the duration of this call.
225    ///
226    /// The path-scoped set above cuts cycles and does nothing about *re-convergence*: a graph
227    /// where every declaration extends `b` parents that later meet again has `b^depth` paths
228    /// through a linear number of declarations — 4^12 ≈ 16.8 million through forty-eight, which
229    /// is minutes inside a single uninterruptible host call. Keyed on the resolved
230    /// `(declaring file, declared name)` pair, which is the only identity that survives
231    /// crossing a file.
232    ///
233    /// Not keyed on depth. **Nothing exhausted by the depth bound is written here at all** —
234    /// see [`Walk::exhausted`] — because an entry written from a truncated subtree is a `None`
235    /// that would be read back at a shallower position where the walk would have answered. The
236    /// walk order is a function of the input, so two runs still answer identically.
237    answers: BTreeMap<(FilePath, String), Option<bool>>,
238    /// How far back the current subtree has reached, as a position on the current path.
239    ///
240    /// [`usize::MAX`] for "nowhere", which is what makes `min` the whole update rule. A cycle
241    /// cut on a key held at index `i` lowers this to `i`, and each frame folds its own value
242    /// into its parent's on the way out — Tarjan's lowlink, for exactly Tarjan's reason: it is
243    /// the cheapest thing that says *which* declarations an answer depended on the path for.
244    ///
245    /// A single global counter of cuts was the first spelling and was far too coarse. It said
246    /// only "a cycle was cut somewhere under here", so one mutual pair at the bottom of a graph
247    /// disabled the memo for every declaration above it, and a re-converging graph went back to
248    /// `width^depth` paths — the exact cost the memo exists to remove.
249    lowlink: usize,
250    /// How many times the depth bound has truncated a subtree on this call.
251    ///
252    /// A subtree the bound cut answered about a *prefix* of the graph rather than about the
253    /// declaration, so its `None` is a property of where it was reached from. Memoizing it
254    /// hands that `None` to a later, shallower reach of the same declaration that the walk
255    /// would have answered — which is what happened to a declaration named both far down a
256    /// chain and directly by the root. Compared before and after a subtree, exactly as the
257    /// lowlink is.
258    exhausted: u32,
259}
260
261impl BuiltinProvider {
262    /// Confirm a grammar speaks TypeScript and build a provider over it, with no second
263    /// grammar — [`Self::probe_with`] is where one is added.
264    ///
265    /// `None` on the same two conditions [`TypeScriptSupport::probe`] refuses on — a grammar
266    /// without the vocabulary this oracle reads, or a language with no binding resolver —
267    /// plus a third: a grammar the parser will not accept at all. Each would otherwise
268    /// produce confident nonsense rather than an error.
269    ///
270    /// The only constructor. A provider must *parse* declaration files, so it cannot be
271    /// built from a resolver alone.
272    #[must_use]
273    pub fn probe(language: &dyn Language) -> Option<Self> {
274        Self::probe_with(language, None)
275    }
276
277    /// [`Self::probe`] with a second grammar, for the `.tsx` files the resolver reaches.
278    ///
279    /// The oracle's vocabulary is still confirmed against the *main* language alone, and the
280    /// support built from it is what answers every question: the tsx grammar speaks the same
281    /// node vocabulary — it is the same resolver, one grammar wider — so a `.tsx` sibling
282    /// needs no second oracle, only a second parse.
283    ///
284    /// `None` when either grammar will not load into a parser, the main probe's own refusal
285    /// unchanged: a resolver that reaches a `.tsx` file a provided grammar cannot parse is
286    /// one that would answer from `ERROR` nodes.
287    #[must_use]
288    pub fn probe_with(language: &dyn Language, tsx: Option<&dyn Language>) -> Option<Self> {
289        let support = TypeScriptSupport::probe(language)?;
290        let mut parser = tree_sitter::Parser::new();
291        parser.set_language(&language.grammar()).ok()?;
292        let tsx = match tsx {
293            None => None,
294            Some(tsx) => Some(TsxParser::probe(tsx)?),
295        };
296        Some(Self {
297            support,
298            grammar_digest: lanekeep_lang::grammar_digest(&language.grammar()),
299            analysis_identity: language.analysis_identity(),
300            parser: Mutex::new(parser),
301            tsx,
302            declarations: Mutex::new(BTreeMap::new()),
303            completeness: Mutex::new(BTreeMap::new()),
304            #[cfg(test)]
305            parses: std::sync::atomic::AtomicUsize::new(0),
306        })
307    }
308
309    /// Whether this provider has a grammar for the dialect `path` is written in.
310    ///
311    /// `.tsx` needs the second grammar; everything else the resolver reaches is read by the
312    /// main one. A path that answers `false` is not read at all — see `walk_export` and
313    /// `complete()` — because a parse in the wrong dialect is wrong even when it is clean:
314    /// `<Foo>bar` is a type assertion to the TypeScript grammar and JSX to the TSX one.
315    fn reads_dialect_of(&self, path: &str) -> bool {
316        self.tsx.is_some() || !extension_is_tsx(path)
317    }
318
319    /// The parser, whether or not another thread died holding it.
320    fn parser(&self) -> MutexGuard<'_, tree_sitter::Parser> {
321        self.parser.lock().unwrap_or_else(PoisonError::into_inner)
322    }
323
324    /// The parser for the file at `path`: the second grammar's when the path's extension is
325    /// `.tsx` — case-insensitively, so the parse agrees with the resolver's suffix probe on
326    /// whatever case the tree spells — and a tsx grammar was given, the main one otherwise.
327    ///
328    /// One guard, whichever mutex it came out of: the caller cannot tell and need not, and
329    /// the two locks are what keep a `.ts` parse and a `.tsx` parse from waiting on each
330    /// other. No tsx grammar, every path answers from the main parser — including a `.tsx`
331    /// one, whose JSX then becomes the `ERROR` nodes `complete()` counts.
332    fn parser_for(&self, path: &str) -> MutexGuard<'_, tree_sitter::Parser> {
333        let tsx = self.tsx.as_ref().filter(|_| extension_is_tsx(path));
334        match tsx {
335            Some(tsx) => tsx.parser.lock().unwrap_or_else(PoisonError::into_inner),
336            None => self.parser(),
337        }
338    }
339
340    /// An oracle over a question's own file, able to follow imports out of it.
341    fn oracle_with<'q>(&'q self, q: &Query<'q>, imports: &'q Imports<'q>) -> TypeScriptOracle<'q> {
342        TypeScriptOracle::new(&self.support, q.tree, q.source).with_imports(q.file, imports)
343    }
344
345    /// The parsed declaration file at `path`, parsed once per version of its bytes — kept
346    /// across `begin_run` now, not cleared to force a re-parse per run.
347    ///
348    /// `None` when nothing is there, when it is not text, or when the grammar refuses it —
349    /// three different reasons and one answer, because a rule can do nothing different with
350    /// any of them and a rule that branched on the difference would give different answers on
351    /// different machines.
352    ///
353    /// **One hash lookup per call, and bytes only when the parse is stale.** The cache is
354    /// keyed by path, and two `FileAccess`es over one path can see two different files — a
355    /// rewrite mid-run, which is routine under `--watch`. Served by path alone, the *second*
356    /// importer would get the *first* version's parse while its own access recorded the new
357    /// bytes' hash, and the entry written then describes neither version: a wrong answer that
358    /// validates forever. So the hash decides, and [`FileAccess::hash_of`] answers it from the
359    /// access's own memo without materializing the text — which is what the previous spelling
360    /// paid, cloning a whole declaration file per importer and re-hashing it to compare
361    /// against a digest the parse already carried.
362    ///
363    /// **Nothing memoizes the failures**, and nothing needs to. A path with no hash is
364    /// re-probed on the next call, which costs one [`FileAccess::hash_of`] — and that access
365    /// has a memo of its own, so within a run the second probe reads nothing from the disk.
366    /// A memo here could only be keyed by path, having no hash to key on, so it would have to
367    /// be cleared wholesale at the start of every run to keep a `.d.ts` installed between two
368    /// runs from staying missing forever.
369    ///
370    /// A path that has *stopped* answering — deleted, or no longer text — has its parse
371    /// dropped rather than left behind. The entry is unservable from here on, because every
372    /// path through this method compares a hash first, so keeping it holds a whole declaration
373    /// file's tree and source until the next `begin_run` for nothing.
374    #[must_use]
375    pub fn declaration(&self, files: &FileAccess, path: &FilePath) -> Option<Arc<Declaration>> {
376        let Ok(Some(hash)) = files.hash_of(path.as_str()) else {
377            self.declarations().remove(path);
378            return None;
379        };
380        if let Some(found) = self.declarations().get(path)
381            && found.hash == hash
382        {
383            return Some(Arc::clone(found));
384        }
385
386        let Ok(Some(source)) = files.read(path.as_str()) else {
387            return None;
388        };
389        #[cfg(test)]
390        self.parses
391            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
392        let parsed = Declaration::parse(
393            path.clone(),
394            source,
395            &mut self.parser_for(path.as_str()),
396            Arc::clone(self.support.resolver()),
397        )?;
398        let parsed = Arc::new(parsed);
399        // Replaces rather than keeps: the bytes this access read are the ones the run is
400        // answering about from here on.
401        self.declarations()
402            .insert(path.clone(), Arc::clone(&parsed));
403        Some(parsed)
404    }
405
406    fn declarations(&self) -> MutexGuard<'_, BTreeMap<FilePath, Arc<Declaration>>> {
407        self.declarations
408            .lock()
409            .unwrap_or_else(PoisonError::into_inner)
410    }
411
412    fn completeness(&self) -> MutexGuard<'_, BTreeMap<FilePath, bool>> {
413        self.completeness
414            .lock()
415            .unwrap_or_else(PoisonError::into_inner)
416    }
417
418    /// How many times [`Self::declaration`] has parsed a file, so far.
419    #[cfg(test)]
420    fn parses(&self) -> usize {
421        self.parses.load(std::sync::atomic::Ordering::Relaxed)
422    }
423
424    /// Follow `name` from `file` through re-exports to the file and name that declare it.
425    ///
426    /// `None` when a link cannot be read, when the name is nowhere, or when the chain
427    /// exceeded `MAX_EXPORT_DEPTH` — one answer for the three, because a rule can do
428    /// nothing different with any of them. `complete()` can, and asks `export_walk` instead.
429    #[must_use]
430    pub fn export_target(
431        &self,
432        files: &FileAccess,
433        file: &FilePath,
434        name: &str,
435    ) -> Option<ExportTarget> {
436        self.export_walk(files, file, name).ok()
437    }
438
439    /// [`Self::export_target`], keeping why a walk did not end at a declaration.
440    fn export_walk(
441        &self,
442        files: &FileAccess,
443        file: &FilePath,
444        name: &str,
445    ) -> Result<ExportTarget, Unreached> {
446        let mut visited = BTreeSet::new();
447        self.walk_export(files, file, name, 0, &mut visited)
448    }
449
450    fn walk_export(
451        &self,
452        files: &FileAccess,
453        file: &FilePath,
454        name: &str,
455        depth: u32,
456        visited: &mut BTreeSet<(FilePath, String)>,
457    ) -> Result<ExportTarget, Unreached> {
458        if depth >= MAX_EXPORT_DEPTH {
459            return Err(Unreached::Unread);
460        }
461        // The visited set rather than the bound alone. `export * from` in both directions is
462        // a shape real packages ship, and a bound would turn an unbounded walk into a merely
463        // slow one — sixteen files opened and parsed per query, on a corpus, is not a cost
464        // worth paying to reach the same answer. A cycle back to a pair already on the walk
465        // declares nothing new, so it is a miss rather than an unread link.
466        if !visited.insert((file.clone(), name.to_owned())) {
467            return Err(Unreached::Unmodeled);
468        }
469
470        // A file in a dialect this provider has no grammar for is not read: parsed with the
471        // main grammar, a JSX statement becomes an `ERROR` that covers only itself and the
472        // clean statement beside it reads as read, and a parse that happens to be clean is
473        // in the wrong dialect all the same. This is the refusal the old `RELATIVE_SUFFIXES`
474        // omission made, kept where `resolve.rs` promises it. Before the parse, so the
475        // answer records no read of a file it does not depend on.
476        if !self.reads_dialect_of(file.as_str()) {
477            return Err(Unreached::Unread);
478        }
479        let decl = self.declaration(files, file).ok_or(Unreached::Unread)?;
480        let Some(exported) = find_export(&decl, name) else {
481            // Nothing here exports the name. In a file the parser read whole that is a fact
482            // about the module; in one it did not, the declaration may sit inside the span
483            // the parser gave up on, and the honest answer is that it was not read.
484            return Err(if decl.has_error {
485                Unreached::Unread
486            } else {
487                Unreached::Unmodeled
488            });
489        };
490        match exported {
491            Exported::Here(node) => {
492                // The one gate on a damaged declaration, for every caller of the walk:
493                // `has_error()` on the reached node counts a `MISSING` token as well as an
494                // `ERROR`, and it is a property of the node whichever tree it sits in.
495                if node.has_error() {
496                    return Err(Unreached::Unread);
497                }
498                Ok(ExportTarget {
499                    file: file.clone(),
500                    name: declared_name(&decl, node).unwrap_or_else(|| name.to_owned()),
501                })
502            }
503            Exported::From {
504                specifier,
505                name: exported,
506            } => {
507                let next = resolve_specifier(files, file, &specifier).ok_or(Unreached::Unread)?;
508                self.walk_export(files, &next, &exported, depth.saturating_add(1), visited)
509            }
510            // A module object has no single declaration, so there is nothing to walk to.
511            Exported::Namespace { .. } => Err(Unreached::Unmodeled),
512            // Source order, first hit wins, so a corpus does not pay for every star source
513            // once one of them answers. A source that cannot be read is read past, the way
514            // the walk always has — `export * from './generated'` beside a live source must
515            // not silence every name the barrel re-exports — and it decides the verdict only
516            // when no source answered: then the name may well sit in the file that could not
517            // be read, and that is unread rather than absent.
518            Exported::Star(sources) => {
519                let mut unread = false;
520                for specifier in &sources {
521                    let Some(next) = resolve_specifier(files, file, specifier) else {
522                        unread = true;
523                        continue;
524                    };
525                    match self.walk_export(files, &next, name, depth.saturating_add(1), visited) {
526                        Err(Unreached::Unmodeled) => {}
527                        Err(Unreached::Unread) => unread = true,
528                        found @ Ok(_) => return found,
529                    }
530                }
531                Err(if unread {
532                    Unreached::Unread
533                } else {
534                    Unreached::Unmodeled
535                })
536            }
537        }
538    }
539
540    /// The declaring file and node for an imported name, or nothing readable.
541    ///
542    /// One place, because all four hook methods start here and the difference between them is
543    /// only what they do with the node.
544    fn imported(
545        &self,
546        files: &FileAccess,
547        from: &FilePath,
548        module: &str,
549        name: &ImportedName,
550    ) -> Option<(Arc<Declaration>, ExportTarget)> {
551        // A namespace import binds the whole module object, which has no declaration to walk
552        // to — the same `None` `Exported::Namespace` produces one layer down.
553        let wanted = match name {
554            ImportedName::Named(exported) => exported.clone(),
555            ImportedName::Default => "default".to_owned(),
556            ImportedName::Namespace => return None,
557        };
558        let entry = resolve_specifier(files, from, module)?;
559        let target = self.export_target(files, &entry, &wanted)?;
560        let decl = self.declaration(files, &target.file)?;
561        Some((decl, target))
562    }
563
564    /// Whether `ty`, read in the file `(tree, source)` is the parse of, is the type declared
565    /// at `target`.
566    ///
567    /// Nominal, never structural. `Some(false)` is a real answer — the walk completed and
568    /// reached nothing — and `None` is "a link in the chain could not be read", which a rule
569    /// must not treat as a negative: a project whose `node_modules` is absent would otherwise
570    /// have every governed value reported. A parent declaration the parser only partly read
571    /// is unreadable the same way — the walk refuses rather than answer over a damaged
572    /// node — and that is read off the node itself (`has_error()`), so it holds in the
573    /// asking file's tree exactly as in a declaration file's.
574    ///
575    /// `target` is resolved once, by [`Self::is_assignable_to`], rather than compared as a
576    /// `(module, name)` pair at every step. A symbol's own `module` field cannot stand in for
577    /// that comparison past the first hop: `TypeScriptOracle::symbol_at` reports a `Symbol`
578    /// whose `module` field is `None` for a name declared *locally* in whichever file the
579    /// oracle is currently reading, and every file this walk steps into is, from its own
580    /// point of view, local — so a `Decimal` found by walking into `money`'s own declaration
581    /// file carries no `module` at all, and a comparison against the string `"money"` would
582    /// silently miss it. Comparing the *resolved* declaring file and name is the check that
583    /// still holds after crossing files.
584    fn assignable(
585        &self,
586        files: &FileAccess,
587        // The file this walk currently stands in: its path, its tree, and its source, bundled
588        // so the whole trio moves as one argument — `assignable`/`heritage_assignable` would
589        // otherwise carry eight parameters apiece and trip `clippy::too_many_arguments`.
590        at: (&FilePath, &tree_sitter::Tree, &str),
591        ty: &Type,
592        target: (&FilePath, &str),
593        depth: u32,
594        walk: &mut Walk,
595    ) -> Option<bool> {
596        let (at_path, tree, source) = at;
597        if depth >= MAX_EXPORT_DEPTH {
598            // Counted, so nothing computed above this point is memoized: the answer this
599            // truncation produces is about the path, not about the declaration.
600            walk.exhausted = walk.exhausted.saturating_add(1);
601            return None;
602        }
603        match ty {
604            Type::Primitive(_) => Some(false),
605            Type::Union(members) => {
606                // Every member, and `None` from any of them sinks the answer: a union one
607                // member of which could not be read is not evidence about the union.
608                let mut all = true;
609                for member in members {
610                    if !self.assignable(files, at, member, target, depth.saturating_add(1), walk)? {
611                        all = false;
612                    }
613                }
614                Some(all)
615            }
616            Type::Nominal {
617                name: written,
618                symbol,
619            } => {
620                // The resolver's own opinion, when it has one. Genuinely no answer — an
621                // ambient global (`Date`, never declared or imported anywhere) — which no
622                // resolver arm can bind because nothing binds it.
623                //
624                // `declared_in` is the fallback that tells an ambient global apart from a
625                // name that only *looks* unbound — a spelling the resolver's walk does not
626                // cover, or a construct a future grammar revision moves — over the *current*
627                // file. When it also finds nothing, this is genuinely unreadable, matching
628                // `symbol_at`'s own contract, which already returns `None` outright rather
629                // than a `Symbol` with empty fields.
630                let (declaring, declared) = if let Some(symbol) = symbol {
631                    match &symbol.module {
632                        Some(specifier) => {
633                            let entry = resolve_specifier(files, at_path, specifier)?;
634                            let exported = symbol.exported.as_deref()?;
635                            let found = self.export_target(files, &entry, exported)?;
636                            (found.file, found.name)
637                        }
638                        None => (at_path.clone(), written.clone()),
639                    }
640                } else {
641                    declared_in(self.support.resolver().as_ref(), tree, source, written)?;
642                    (at_path.clone(), written.clone())
643                };
644
645                if declaring == *target.0 && declared == target.1 {
646                    return Some(true);
647                }
648                let key = (declaring.clone(), declared.clone());
649                // Answered already, on some other path through this graph — see `Walk::answers`
650                // for why one declaration's answer is the same wherever it recurs.
651                if let Some(known) = walk.answers.get(&key) {
652                    return *known;
653                }
654                if let Some(&reached) = walk.visiting.get(&key) {
655                    // Already walked *on this path*. `false` rather than `None`: a cycle is a
656                    // fully read program that does not reach the named type, not an
657                    // unreadable one. Recorded as a back-edge to the position the cycle
658                    // reaches, which is what stops the poison at the declarations really
659                    // inside it rather than spreading it to the whole ancestor chain.
660                    walk.lowlink = walk.lowlink.min(reached);
661                    return Some(false);
662                }
663
664                // The position this declaration takes on the current path. Positions are
665                // handed out by path length, so they increase strictly downward and a
666                // back-edge to a smaller one is a cycle escaping this subtree.
667                let index = walk.visiting.len();
668                walk.visiting.insert(key.clone(), index);
669                let outer_lowlink = walk.lowlink;
670                walk.lowlink = usize::MAX;
671                let exhausted_before = walk.exhausted;
672                let result = if &declaring == at_path {
673                    self.heritage_assignable(files, at, &declared, target, depth, walk)
674                } else {
675                    let decl = self.declaration(files, &declaring);
676                    match decl {
677                        Some(decl) => self.heritage_assignable(
678                            files,
679                            (&decl.path, &decl.tree, &decl.source),
680                            &declared,
681                            target,
682                            depth,
683                            walk,
684                        ),
685                        None => None,
686                    }
687                };
688                // Removed once this node's own answer is known, so a sibling branch that
689                // reaches the same ancestor through a different path is not told it was
690                // "already walked" by a walk that has since returned.
691                walk.visiting.remove(&key);
692                let reached = walk.lowlink;
693                // The parent inherits it: a back-edge past *this* node is one past every node
694                // above it too. A back-edge that stopped here is `>= index`, which is larger
695                // than any ancestor's own index and so cannot block one.
696                walk.lowlink = outer_lowlink.min(reached);
697                if reached >= index && walk.exhausted == exhausted_before {
698                    // Nothing under it reached back past it and nothing under it was truncated
699                    // by the depth bound, so this answer is a property of the declaration
700                    // rather than of the path that reached it.
701                    walk.answers.insert(key, result);
702                }
703                result
704            }
705        }
706    }
707
708    /// Walk one declaration's parents.
709    ///
710    /// The oracle built here carries no [`ImportResolution`] — deliberately bare, unlike
711    /// every oracle [`Self::type_of`] and friends hand out. With one attached,
712    /// [`TypeScriptOracle::type_named_by`] would follow an imported alias to its declaration
713    /// *inside this call*, across a file boundary this function never sees: the `Type` it
714    /// hands back carries a symbol but no file, so the crossing would be invisible to
715    /// [`Self::assignable`]'s own `at` tracking and the walk would silently lose the file it
716    /// is really standing in. Left bare, the oracle reports the raw binding — imported or
717    /// local, alias or not — and every crossing happens through `assignable`'s own
718    /// `declaring`/`declared` resolution instead, which is the only place `at` is updated.
719    fn heritage_assignable(
720        &self,
721        files: &FileAccess,
722        at: (&FilePath, &tree_sitter::Tree, &str),
723        declared: &str,
724        target: (&FilePath, &str),
725        depth: u32,
726        walk: &mut Walk,
727    ) -> Option<bool> {
728        let (_, tree, source) = at;
729        // The asking file is parsed by the *engine* and is deliberately not in the
730        // declaration cache — re-reading it here would be a second parse of a file already
731        // parsed, which is what `local/one-parser-per-file` exists to catch. So `declared_in`
732        // runs directly over the tree this walk was already handed.
733        let Some(declaration) =
734            declared_in(self.support.resolver().as_ref(), tree, source, declared)
735        else {
736            // The name is not declared where the symbol said it was, which is a program this
737            // provider could not read rather than one it read and rejected.
738            return None;
739        };
740        // A parent the parser only partly read — an `ERROR` it recovered inside the body, or
741        // a `MISSING` token it inserted — is unreadable, never a negative, the same reasoning
742        // the walk's other `None`s carry. Read off the node, so the asking file's own tree
743        // gets the same answer a declaration file's does.
744        if declaration.has_error() {
745            return None;
746        }
747        // Told when its own bound is what answered nothing. The walk threads the depth it has
748        // already spent into `type_of_from` below, so the oracle can give up on `MAX_DEPTH`
749        // several frames down and hand back a `None` that describes the path rather than the
750        // alias — which `assignable` would then memoize against the declaration. One `Cell`
751        // per call, living exactly as long as the oracle that writes it.
752        let truncated = Cell::new(false);
753        let oracle = TypeScriptOracle::new(&self.support, tree, source).with_exhaustion(&truncated);
754
755        // An alias is transparent: `export type Money = Decimal` is `Decimal`. Read with
756        // `type_of_from` rather than `type_named_by`: the latter always types the alias's
757        // right-hand side in *nominal* position (`named_type`, which answers `Nominal` for
758        // anything it cannot resolve, `number` included) and resets depth to zero on every
759        // call, both wrong here. `type_of_from` types the value on its own terms — a
760        // primitive right-hand side (`export type Amount = number`) comes back as
761        // `Type::Primitive`, which `assignable`'s own `Type::Primitive(_) => Some(false)` arm
762        // then answers honestly instead of failing to resolve a bare `number` as a nominal
763        // name and returning `None` — and threads the depth this call has already spent
764        // instead of restarting it, which `depth never resets` requires.
765        if declaration.kind() == "type_alias_declaration"
766            && let Some(value) = declaration.child_by_field_name("value")
767        {
768            if let Some(aliased) = oracle.type_of_from(value, depth) {
769                return self.assignable(files, at, &aliased, target, depth.saturating_add(1), walk);
770            }
771            // Nothing came back. When the *oracle's* bound is why, the fall-through below is a
772            // lie by construction: an alias declares no parents, so the loop answers
773            // `Some(false)` for a chain that was never read to its end, and `assignable`
774            // memoizes that against the alias — where a later, shallower reach of the same
775            // alias reads it back instead of the `Some(true)` the walk would have produced.
776            // Counted exactly as the walk's own truncation is, so the subtree stays unmemoized.
777            if truncated.get() {
778                walk.exhausted = walk.exhausted.saturating_add(1);
779            }
780        }
781
782        let mut answer = Some(false);
783        for parent in heritage_of(declaration) {
784            let Some(parent_type) = oracle.type_named_by(parent) else {
785                // A parent the oracle cannot name makes the whole walk unreadable, not
786                // negative — the same reasoning the `symbol.is_none()` arm above uses.
787                answer = None;
788                continue;
789            };
790            match self.assignable(
791                files,
792                at,
793                &parent_type,
794                target,
795                depth.saturating_add(1),
796                walk,
797            ) {
798                Some(true) => return Some(true),
799                Some(false) => {}
800                None => answer = None,
801            }
802        }
803        answer
804    }
805
806    /// The type of a property access or subscript at `node`, folded across files.
807    ///
808    /// The member *names* come from the asking file's expression; the receiver each hop reads
809    /// from lives in whichever file last declared it. So the base is typed in the asking file
810    /// and the chain is folded from there, threading the file it stands in exactly as
811    /// [`Self::assignable`] does — a `Type::Nominal` returned across a hop carries a specifier
812    /// relative to the file that produced it, so folding through the asking file's oracle
813    /// instead would resolve the second hop against the wrong directory. The oracle's own
814    /// within-file `member_access` is the same walk without the file-crossing; this is why the
815    /// two exist.
816    fn member_access(
817        &self,
818        files: &FileAccess,
819        at: (&FilePath, &tree_sitter::Tree, &str),
820        node: tree_sitter::Node<'_>,
821    ) -> Option<Type> {
822        let (base, members) = member_path(at.2, node)?;
823        let (base_type_node, base_nullish) =
824            TypeScriptOracle::new(&self.support, at.1, at.2).annotated_type_node(base)?;
825        self.type_chain(files, at, base_type_node, base_nullish, &members, 0)
826    }
827
828    /// Fold `members` — each a name and whether its link is optional — onto `type_node`, a type
829    /// node in file `at`, crossing files at imported type references.
830    ///
831    /// `nullish` accumulates the chain's short-circuiting: once any link is optional or any
832    /// receiver is nullable, the whole tail is `| undefined`.
833    fn type_chain(
834        &self,
835        files: &FileAccess,
836        at: (&FilePath, &tree_sitter::Tree, &str),
837        type_node: tree_sitter::Node<'_>,
838        nullish: bool,
839        members: &[(String, bool)],
840        depth: u32,
841    ) -> Option<Type> {
842        let Some(((member, optional), rest)) = members.split_first() else {
843            // `type_node` is the last member's type node, read in the file it lives in.
844            return with_optional(
845                TypeScriptOracle::new(&self.support, at.1, at.2)
846                    .annotation_type_from(type_node, 0)?,
847                nullish,
848            );
849        };
850        if depth >= MAX_EXPORT_DEPTH {
851            return None;
852        }
853        let receiver_nullish = type_contains_nullish(type_node);
854        self.with_container(files, at, type_node, depth, &mut |at2, container| {
855            let (annotation, member_optional) = member_annotation(at2.2, container, member)?;
856            self.type_chain(
857                files,
858                at2,
859                annotation_child(annotation)?,
860                nullish || receiver_nullish || *optional || member_optional,
861                rest,
862                depth.saturating_add(1),
863            )
864        })
865    }
866
867    /// Resolve a type node to the member container it denotes — following aliases and stripping a
868    /// nullable union's arms, crossing files at an imported reference — and hand the container,
869    /// with the file it lives in, to `f`.
870    ///
871    /// The callback runs while this frame still holds the declaring file's parse, so the
872    /// container node it is handed stays valid; a chain continued inside `f` keeps every file it
873    /// has opened alive on the stack, one frame per hop.
874    fn with_container(
875        &self,
876        files: &FileAccess,
877        at: (&FilePath, &tree_sitter::Tree, &str),
878        type_node: tree_sitter::Node<'_>,
879        depth: u32,
880        f: &mut ContainerSink<'_>,
881    ) -> Option<Type> {
882        if depth >= MAX_EXPORT_DEPTH {
883            return None;
884        }
885        match type_node.kind() {
886            "object_type" => f(at, type_node),
887            "parenthesized_type" => self.with_container(
888                files,
889                at,
890                type_node.named_child(0)?,
891                depth.saturating_add(1),
892                f,
893            ),
894            "union_type" => self.with_container(
895                files,
896                at,
897                sole_non_nullish_arm(type_node)?,
898                depth.saturating_add(1),
899                f,
900            ),
901            "type_identifier" | "generic_type" => {
902                let name = type_name_node(type_node)?;
903                if let Some(Binding::Import {
904                    module,
905                    name: imported,
906                }) = self.support.resolver().resolve(at.1, at.2, name)
907                {
908                    let (decl, target) = self.imported(files, at.0, &module, &imported)?;
909                    let node = target_node(&decl, &target.name)?;
910                    self.declaration_container(
911                        files,
912                        (&decl.path, &decl.tree, &decl.source),
913                        node,
914                        depth.saturating_add(1),
915                        f,
916                    )
917                } else {
918                    // Scope-aware, so a type name shadowed inside a function in the asking file
919                    // resolves to the shadow, not a same-named top-level declaration — the same
920                    // fix `resolve_to_container` makes in the oracle. In a declaration file the
921                    // relevant types are top-level anyway, so this is no worse there and correct
922                    // in the asking file.
923                    let node = self.support.resolver().declaration_of(at.1, at.2, name)?;
924                    if node.has_error() {
925                        return None;
926                    }
927                    self.declaration_container(files, at, node, depth.saturating_add(1), f)
928                }
929            }
930            _ => None,
931        }
932    }
933
934    /// The member container of a declaration node, following a type alias's right-hand side.
935    fn declaration_container(
936        &self,
937        files: &FileAccess,
938        at: (&FilePath, &tree_sitter::Tree, &str),
939        declaration: tree_sitter::Node<'_>,
940        depth: u32,
941        f: &mut ContainerSink<'_>,
942    ) -> Option<Type> {
943        if declaration.kind() == "type_alias_declaration" {
944            return self.with_container(
945                files,
946                at,
947                declaration.child_by_field_name("value")?,
948                depth.saturating_add(1),
949                f,
950            );
951        }
952        f(at, declaration_body(declaration)?)
953    }
954}
955
956/// The base expression of a property-access/subscript chain, and the members read off it in
957/// order — outermost last.
958///
959/// `a?.b["c"]` gives base `a` and `[("b", true), ("c", false)]`. Parentheses are transparent.
960/// A base that is not a member access (an identifier, a call, `this`) ends the walk and is
961/// returned for the caller to type.
962fn member_path<'t>(
963    source: &str,
964    node: tree_sitter::Node<'t>,
965) -> Option<(tree_sitter::Node<'t>, Vec<(String, bool)>)> {
966    let mut members = Vec::new();
967    let mut current = node;
968    loop {
969        match current.kind() {
970            "member_expression" | "subscript_expression" => {
971                members.push((member_name(source, current)?, optional_access(current)));
972                current = current.child_by_field_name("object")?;
973            }
974            "parenthesized_expression" => current = current.named_child(0)?,
975            _ => break,
976        }
977    }
978    members.reverse();
979    Some((current, members))
980}
981
982/// Whether a specifier names something this resolver could read as TypeScript.
983///
984/// A bundler's project imports a stylesheet, a JSON asset and an image the same way it imports
985/// a module. None of those is a module the oracle reads, all of them fail every probe, and
986/// counting them makes `complete()` `false` for most files in a React codebase — where the
987/// label then says "this project has CSS" rather than "a type answer is missing", which is the
988/// one thing it exists to say.
989///
990/// **A denylist of asset extensions, never an allowlist of code ones**, because the two fail
991/// in opposite directions and only one of the two failures is safe. An allowlist read
992/// `./user.service` as an extension `service`, found it in no list of code extensions, and
993/// skipped the import unprobed — so `complete()` answered `true` for a file whose imports were
994/// never resolved, which is the one claim the flag must never make. That spelling is a
995/// convention rather than a curiosity: `.service`, `.component`, `.module`, `.dto`, `.entity`,
996/// `.guard`, `.pipe` and `.config` are how NestJS and Angular projects name most of their
997/// files. A denylist that misses an asset kind costs eight absent probes and an honest
998/// `complete() == false`; an allowlist that misses a naming convention costs a silent lie.
999fn reads_as_code(specifier: &str) -> bool {
1000    let last = specifier.rsplit('/').next().unwrap_or(specifier);
1001    match last.rsplit_once('.') {
1002        // No extension at all is the ordinary spelling of a module.
1003        None => true,
1004        Some((_, extension)) => !ASSET_EXTENSIONS.contains(&extension),
1005    }
1006}
1007
1008/// Extensions a bundler resolves that are not programs.
1009///
1010/// Stylesheets, data, images, fonts, prose, schemas and media — everything a loader turns into
1011/// a value without any of it being TypeScript. `.jsx` is deliberately **not** here: the
1012/// resolver strips it to the stem the way it strips `.js` (see `relative`), so a `.jsx`
1013/// specifier reaches a `.tsx` or `.ts` source, and one that reaches nothing is a real
1014/// incompleteness a file should be told about rather than an asset to skip over. `.tsx` is
1015/// resolved and parsed, so it belongs here no more than `.ts` does.
1016const ASSET_EXTENSIONS: &[&str] = &[
1017    "css", "scss", "sass", "less", "styl", "json", "svg", "png", "jpg", "jpeg", "gif", "webp",
1018    "avif", "ico", "woff", "woff2", "ttf", "eot", "otf", "md", "mdx", "txt", "yaml", "yml", "toml",
1019    "graphql", "gql", "wasm", "mp4", "webm", "mp3",
1020];
1021
1022/// The type names one declaration extends or implements.
1023///
1024/// **Three different node shapes, and a walk that handles one is the obvious bug.** A class
1025/// carries `class_heritage` → `extends_clause`, whose `value` field is `"multiple": true`,
1026/// and optionally `class_heritage` → `implements_clause`, whose members carry no field name
1027/// at all; an interface carries `extends_type_clause` directly, whose `type` field is also
1028/// multiple. All three were read off `node-types.json` rather than off a sample.
1029fn heritage_of(declaration: tree_sitter::Node<'_>) -> Vec<tree_sitter::Node<'_>> {
1030    let mut out = Vec::new();
1031    let mut cursor = declaration.walk();
1032    for child in declaration.named_children(&mut cursor) {
1033        match child.kind() {
1034            "class_heritage" => {
1035                let mut inner = child.walk();
1036                for clause in child.named_children(&mut inner) {
1037                    match clause.kind() {
1038                        "extends_clause" => collect_field(clause, "value", &mut out),
1039                        // A declared `implements` is a nominal relationship too — see
1040                        // `is_assignable_to`'s doc. `implements_clause` names its members
1041                        // with no field (`node-types.json` gives it `children`, not
1042                        // `fields`), unlike `extends_clause`'s `value`, so its types are
1043                        // walked as plain named children rather than through
1044                        // `collect_field`.
1045                        "implements_clause" => {
1046                            let mut types = clause.walk();
1047                            for interface in clause
1048                                .named_children(&mut types)
1049                                .filter(|child| child.kind() != "comment")
1050                            {
1051                                out.push(inner_type_name(interface));
1052                            }
1053                        }
1054                        _ => {}
1055                    }
1056                }
1057            }
1058            "extends_type_clause" => collect_field(child, "type", &mut out),
1059            _ => {}
1060        }
1061    }
1062    out
1063}
1064
1065/// Every child under one field name, which tree-sitter exposes one at a time.
1066fn collect_field<'t>(
1067    node: tree_sitter::Node<'t>,
1068    field: &str,
1069    out: &mut Vec<tree_sitter::Node<'t>>,
1070) {
1071    let mut cursor = node.walk();
1072    for child in node.children_by_field_name(field, &mut cursor) {
1073        out.push(inner_type_name(child));
1074    }
1075}
1076
1077/// The bare name inside a possibly-generic type reference.
1078///
1079/// `Decimal<T>` parses as `generic_type` with a `name` field; type arguments are dropped
1080/// throughout this crate, so the name is what the walk follows.
1081fn inner_type_name(node: tree_sitter::Node<'_>) -> tree_sitter::Node<'_> {
1082    if node.kind() == "generic_type" {
1083        node.child_by_field_name("name").unwrap_or(node)
1084    } else {
1085        node
1086    }
1087}
1088
1089/// One call's worth of a provider, so the oracle can ask it questions.
1090///
1091/// `ImportResolution`'s methods take no [`FileAccess`], because an oracle has no business
1092/// knowing there is one — but a provider needs the caller's, and the caller's changes per
1093/// question. Pairing the two in a value that lives exactly as long as the call is what lets
1094/// the trait stay narrow.
1095struct Imports<'a> {
1096    provider: &'a BuiltinProvider,
1097    files: &'a FileAccess,
1098    /// Set once, anywhere in this call's recursion, the moment a hop finds `depth` already at
1099    /// `MAX_DEPTH`.
1100    ///
1101    /// A single `Option<Type>` cannot carry "the chain was cut" back through more than one
1102    /// level of recursion: `imported_alias_type` calls into a *nested* oracle, whose own
1103    /// `named_type` may call back into `imported_alias_type` several more times before the
1104    /// bound is finally spent, and every one of those intermediate frames sees only a plain
1105    /// `None` from the level below it — indistinguishable, on the type alone, from "this
1106    /// value simply could not be typed". Sharing one flag across every `Imports` built during
1107    /// one top-level call is what lets a frame several hops away from the exhaustion still
1108    /// answer [`Followed::Exhausted`](crate::oracle::Followed) rather than falling back to a
1109    /// nominal guess. Scoped to one call: each `TypeProvider` entry point starts a fresh
1110    /// `Cell`, so nothing here crosses calls, files, or worker threads.
1111    exhausted: &'a Cell<bool>,
1112}
1113
1114impl ImportResolution for Imports<'_> {
1115    fn imported_value_type(
1116        &self,
1117        from: &FilePath,
1118        module: &str,
1119        name: &ImportedName,
1120        depth: u32,
1121    ) -> Option<Type> {
1122        let (decl, target) = self.provider.imported(self.files, from, module, name)?;
1123        let node = target_node(&decl, &target.name)?;
1124        // Typed in the *declaring* file's own context, with the same resolver and the same
1125        // resolution, so a chain of re-exports and aliases is one recursion under one bound.
1126        let nested = Imports {
1127            provider: self.provider,
1128            files: self.files,
1129            exhausted: self.exhausted,
1130        };
1131        let oracle = TypeScriptOracle::new(&self.provider.support, &decl.tree, &decl.source)
1132            .with_imports(&decl.path, &nested);
1133        oracle.declaration_type_from(node, depth)
1134    }
1135
1136    fn imported_alias_type(
1137        &self,
1138        from: &FilePath,
1139        module: &str,
1140        name: &ImportedName,
1141        depth: u32,
1142    ) -> Followed {
1143        // The bound is checked here, before any work, rather than left to the nested oracle's
1144        // own check inside `type_of_from`: that check answers a bare `None`, and this frame
1145        // needs to say *why* there is no type, which only it can decide before recursing.
1146        if depth >= MAX_DEPTH {
1147            self.exhausted.set(true);
1148            return Followed::Exhausted;
1149        }
1150        let Some((decl, target)) = self.provider.imported(self.files, from, module, name) else {
1151            return Followed::NotAnAlias;
1152        };
1153        let Some(node) = target_node(&decl, &target.name) else {
1154            return Followed::NotAnAlias;
1155        };
1156        // Only an alias. A class or an interface keeps its use-site symbol, which is what the
1157        // caller does on `NotAnAlias` — see `ImportResolution`'s own documentation.
1158        if node.kind() != "type_alias_declaration" {
1159            return Followed::NotAnAlias;
1160        }
1161        let Some(value) = node.child_by_field_name("value") else {
1162            return Followed::NotAnAlias;
1163        };
1164        let nested = Imports {
1165            provider: self.provider,
1166            files: self.files,
1167            exhausted: self.exhausted,
1168        };
1169        let oracle = TypeScriptOracle::new(&self.provider.support, &decl.tree, &decl.source)
1170            .with_imports(&decl.path, &nested);
1171        match oracle.type_of_from(value, depth) {
1172            Some(ty) => Followed::Type(ty),
1173            // `self.exhausted` may have been set by a hop deeper than this one — the walk
1174            // that just returned `None` can be several files past where the bound was
1175            // actually spent, and this is the only place that flag is read back.
1176            None if self.exhausted.get() => Followed::Exhausted,
1177            None => Followed::NotAnAlias,
1178        }
1179    }
1180
1181    fn imported_return_type(
1182        &self,
1183        from: &FilePath,
1184        module: &str,
1185        name: &ImportedName,
1186        depth: u32,
1187    ) -> Option<Type> {
1188        let (decl, target) = self.provider.imported(self.files, from, module, name)?;
1189        let node = target_node(&decl, &target.name)?;
1190        let nested = Imports {
1191            provider: self.provider,
1192            files: self.files,
1193            exhausted: self.exhausted,
1194        };
1195        let oracle = TypeScriptOracle::new(&self.provider.support, &decl.tree, &decl.source)
1196            .with_imports(&decl.path, &nested);
1197        oracle.return_type_from(node, depth)
1198    }
1199
1200    fn imported_export(
1201        &self,
1202        from: &FilePath,
1203        module: &str,
1204        name: &ImportedName,
1205    ) -> Option<ExportTarget> {
1206        self.provider
1207            .imported(self.files, from, module, name)
1208            .map(|(_, target)| target)
1209    }
1210}
1211
1212impl TypeProvider for BuiltinProvider {
1213    fn type_of(&self, q: Query<'_>) -> Option<Type> {
1214        // A property access or subscript is folded here rather than in the oracle: each hop's
1215        // receiver may be declared in another file, and the specifier that names it is relative
1216        // to *that* file, not the asking one — so the walk has to thread the file it stands in,
1217        // which needs the `FileAccess` only the provider holds.
1218        if matches!(q.node.kind(), "member_expression" | "subscript_expression") {
1219            return self.member_access(q.files, (q.file, q.tree, q.source), q.node);
1220        }
1221        let exhausted = Cell::new(false);
1222        let imports = Imports {
1223            provider: self,
1224            files: q.files,
1225            exhausted: &exhausted,
1226        };
1227        self.oracle_with(&q, &imports).type_of(q.node)
1228    }
1229
1230    fn symbol_of(&self, q: Query<'_>) -> Option<Symbol> {
1231        let exhausted = Cell::new(false);
1232        let imports = Imports {
1233            provider: self,
1234            files: q.files,
1235            exhausted: &exhausted,
1236        };
1237        self.oracle_with(&q, &imports).symbol_of(q.node)
1238    }
1239
1240    fn return_type_of(&self, q: Query<'_>) -> Option<Type> {
1241        let exhausted = Cell::new(false);
1242        let imports = Imports {
1243            provider: self,
1244            files: q.files,
1245            exhausted: &exhausted,
1246        };
1247        self.oracle_with(&q, &imports).return_type_of(q.node)
1248    }
1249
1250    /// Whether the type at `q.node` is the type `module` exports as `name`, or declares a
1251    /// relationship to it — `extends` or `implements` — across files, through aliases of the
1252    /// named type. See [`TypeProvider::is_assignable_to`] for the full contract, including
1253    /// its narrowings (declaration merging, a generic annotation at the use site, and an
1254    /// unexported target name) and its documented gap (no function-local scoping — a
1255    /// shadowing declaration inside a function is not distinguished from the top-level one).
1256    fn is_assignable_to(&self, q: Query<'_>, module: &str, name: &str) -> Option<bool> {
1257        // Resolved once: the file and name `(module, name)` designates, so `assignable` has a
1258        // fixed target to compare a declaring file against however many files the walk
1259        // crosses. See `assignable`'s own documentation for why a per-step string comparison
1260        // against `module`/`name` cannot do this job.
1261        let entry = resolve_specifier(q.files, q.file, module)?;
1262        let target = self.export_target(q.files, &entry, name)?;
1263
1264        // Bare, for the same reason `heritage_assignable`'s oracle is: `type_of` on a
1265        // `type_annotation` would otherwise follow an imported alias to its declaration
1266        // before `assignable` ever sees the type, crossing a file boundary `assignable`'s
1267        // own `at` tracking never learns about.
1268        let ty = TypeScriptOracle::new(&self.support, q.tree, q.source).type_of(q.node)?;
1269        // Fresh per call: nothing here crosses calls, files or worker threads.
1270        let mut walk = Walk {
1271            visiting: BTreeMap::new(),
1272            answers: BTreeMap::new(),
1273            lowlink: usize::MAX,
1274            exhausted: 0,
1275        };
1276        self.assignable(
1277            q.files,
1278            (q.file, q.tree, q.source),
1279            &ty,
1280            (&target.file, &target.name),
1281            0,
1282            &mut walk,
1283        )
1284    }
1285
1286    /// Whether every import in `q`'s file resolved to a declaration this provider could read.
1287    ///
1288    /// Eager rather than lazy: every specifier is resolved here, not only the ones a rule
1289    /// happened to ask about, because a miss has to be recorded as a dependency even when
1290    /// nothing went looking for the type behind it — the cache's own read on this file must
1291    /// see every candidate path an import could have named, so that a declaration appearing
1292    /// later invalidates a rule that stayed silent for its absence. Memoized per file, since
1293    /// several rules ask the same question about the same file within one run.
1294    ///
1295    /// One thing it deliberately does not count: a specifier that is not code — `./app.css`,
1296    /// `./data.json`, `./logo.svg` — is skipped entirely, probes and all. It is not a module
1297    /// this oracle reads, and counting it would label most of a bundler's project incomplete
1298    /// for having stylesheets.
1299    ///
1300    /// **The contract is resolve-and-parse, judged per declaration where one is reached.**
1301    /// `tree_sitter::Parser::parse` answers a tree for any UTF-8 input, so a file this
1302    /// provider could not fully read shows up only as parse faults — `ERROR` nodes, and the
1303    /// `MISSING` tokens an unclosed brace leaves — and the whole-file verdict this once asked
1304    /// let one damaged statement mark every importer of the file incomplete, project-wide,
1305    /// throwing away the declarations outside the damaged span that answer normally (#229).
1306    /// Each named import is walked to the node that declares it, through re-exports like
1307    /// every other arm, and the name is unread when a link of that chain could not be read:
1308    /// a specifier that resolves to nothing, a file that will not parse, a reached
1309    /// declaration whose own subtree the parser did not finish (`has_error()` on the node,
1310    /// which counts both kinds of fault), or a file in a dialect this provider has no grammar
1311    /// for, which it does not read at all. A walk that ends on a *clean* module with no
1312    /// export it can model is not unread — `export = X` beside `declare namespace X` is that
1313    /// shape for every member of `X`, and counting it silenced every rule on every file
1314    /// naming one (the #232 review). A nameless import — a side-effect one, or a namespace
1315    /// binding, or `export *` — has no single node to reach: a side-effect import asserts the
1316    /// module's whole shape and a namespace import binds a module object whose members can
1317    /// be anything, so both keep the whole-file verdict.
1318    fn complete(&self, q: Query<'_>) -> bool {
1319        if let Some(known) = self.completeness().get(q.file) {
1320            return *known;
1321        }
1322
1323        let mut complete = true;
1324        for imported in imports_with_names(q.tree, q.source) {
1325            // A stylesheet, a JSON asset or an image is not a module this oracle reads, and a
1326            // bundler's `import './app.css'` is not a missing type answer — see `reads_as_code`.
1327            // Skipped before the probe rather than after it, so nothing about it is recorded
1328            // either: eight absent reads per such import, on a codebase where most files have
1329            // one, is cache-entry size spent on a question nobody asked.
1330            if !reads_as_code(&imported.specifier) {
1331                continue;
1332            }
1333            // Resolved once per specifier, before the name loop: which reads the *specifier*
1334            // itself records must not depend on how many names share the module. (The
1335            // per-name chains below add their own reads — that is the pass recording what it
1336            // really consulted; the access memo keeps a repeated path from being recorded
1337            // twice.)
1338            let Some(file) = resolve_specifier(q.files, q.file, &imported.specifier) else {
1339                complete = false;
1340                continue;
1341            };
1342            // A dialect this provider has no grammar for is not read, named or nameless —
1343            // see `reads_dialect_of` — and nothing is parsed to find that out.
1344            if !self.reads_dialect_of(file.as_str()) {
1345                complete = false;
1346                continue;
1347            }
1348            let Some(decl) = self.declaration(q.files, &file) else {
1349                // A specifier that names a file this provider cannot parse is exactly as
1350                // partial as one that names nothing: either way no answer about a name from
1351                // that module was reached by reading anything.
1352                complete = false;
1353                continue;
1354            };
1355            // Nameless: no single declaration to reach, so whatever the parse carries
1356            // counts. The namespace arm of a mixed clause (`import d, * as ns`) is judged
1357            // the same way, because the module object it binds reaches everywhere.
1358            if imported.names.is_empty() || imported.names.contains(&ImportedName::Namespace) {
1359                if decl.has_error {
1360                    complete = false;
1361                }
1362                continue;
1363            }
1364            for name in &imported.names {
1365                let wanted = match name {
1366                    ImportedName::Named(exported) => exported.as_str(),
1367                    ImportedName::Default => "default",
1368                    // Handled with the nameless arm above; unreachable from the enumeration
1369                    // `imports_with_names` does, and the nameless reading is what a
1370                    // namespace binding means if one ever arrives here.
1371                    ImportedName::Namespace => continue,
1372                };
1373                // The contract is resolve-and-parse, per declaration where one is reached.
1374                // A name is unread when a link of its chain could not be read — a specifier
1375                // that resolves to nothing, a file that will not parse, a reached
1376                // declaration the parser did not finish — and *not* when a clean module
1377                // simply has no export the walk can model. `export = X` beside `declare
1378                // namespace X` is that second case for every member of `X`, and it is the
1379                // shape most `@types` packages ship: counting it silenced every rule on every
1380                // file naming one of their members, which is what the #232 review found.
1381                if matches!(
1382                    self.export_walk(q.files, &file, wanted),
1383                    Err(Unreached::Unread)
1384                ) {
1385                    complete = false;
1386                }
1387            }
1388        }
1389
1390        self.completeness().insert(q.file.clone(), complete);
1391        complete
1392    }
1393
1394    /// Start a run, and answer no key term.
1395    ///
1396    /// **Only `completeness` is cleared here.** It carries no hash to compare against — a
1397    /// verdict over a whole file's imports, not a single read — so a provider held across
1398    /// requests (#191) would otherwise answer a second run from the first run's filesystem: a
1399    /// file whose imports did not resolve would stay incomplete forever. `declarations` is
1400    /// *not* cleared: it is keyed by content hash, [`Self::declaration`] compares that hash on
1401    /// every access, and a stale entry is therefore never served whether or not this method
1402    /// touched it. Clearing it here would only cost the parse back — and for a held provider,
1403    /// re-paying that cost every request is the exact overhead holding the provider exists to
1404    /// remove. [`Self::revalidate`] is what drops a hash-mismatched entry proactively, ahead
1405    /// of `declaration()` finding out the hard way.
1406    ///
1407    /// The file list is never asked for: this provider's dependencies are the tracked reads
1408    /// on each entry, so there is nothing to build up front. Answering an empty term is what
1409    /// keeps the builtin provider out of `analysis_hash`'s `programs` field.
1410    fn begin_run(
1411        &self,
1412        files: &dyn Fn() -> Vec<FilePath>,
1413        budget: AnalysisBudget,
1414    ) -> Result<Vec<u8>, BeginRunError> {
1415        // Neither is read: there is nothing to build up front, so there is nothing for a
1416        // budget to bound either.
1417        let _ = (files, budget);
1418        self.completeness().clear();
1419        Ok(Vec::new())
1420    }
1421
1422    fn identity(&self) -> Vec<u8> {
1423        // Tagged as well as hashed. `oracle_identity` alone would let a future provider that
1424        // happened to derive its identity the same way collide with this one, and the tag is
1425        // what makes "which provider answered" part of the key rather than an inference.
1426        //
1427        // After the tag, the main grammar's shape digest, then the tsx grammar's behind a
1428        // presence byte, then the resolver's analysis identity: the digests say *which*
1429        // grammar parses each dialect — `TypeScript` and `Tsx` share one analysis identity,
1430        // so that term alone could not tell a provider over one from a provider over the
1431        // other — and the presence byte, with it and only with it, carries a second grammar,
1432        // so a provider built with one can never fold to the same bytes as one built without
1433        // it. A key that cannot tell two runs apart lets one warm the other's cache. The
1434        // oracle's identity stays last, the one field every provider over every grammar pair
1435        // carries. `the_identity_folds_both_grammar_digests_and_the_resolver` pins the
1436        // layout byte for byte, because a test of inequality alone is satisfied by the
1437        // vectors' lengths.
1438        let mut out = Vec::with_capacity(8 + 32 + 1 + 32 + 32 + 32);
1439        out.extend_from_slice(b"builtin:");
1440        out.extend_from_slice(&self.grammar_digest);
1441        if let Some(tsx) = &self.tsx {
1442            out.push(1);
1443            out.extend_from_slice(&tsx.grammar_digest);
1444        }
1445        out.extend_from_slice(&self.analysis_identity);
1446        out.extend_from_slice(&crate::oracle_identity());
1447        out
1448    }
1449
1450    fn revalidate(&self, files: &FileAccess) {
1451        // Every held declaration is keyed by the content hash it was parsed from; one whose
1452        // bytes moved, or which is gone, is dropped and re-parsed on its next `declaration()`
1453        // call. Completeness carries no hash to compare against — it is a verdict over a
1454        // whole file's imports, not a single read — so it is simply forgotten, the same
1455        // coarse-but-correct move `begin_run` already makes for it.
1456        self.declarations().retain(|path, decl| {
1457            matches!(files.hash_of(path.as_str()), Ok(Some(hash)) if hash == decl.hash)
1458        });
1459        self.completeness().clear();
1460    }
1461}
1462
1463/// `BuiltinProvider` is shareable, checked at compile time rather than believed.
1464///
1465/// The engine holds one in an `Arc` that rayon moves between workers, so a field that is not
1466/// `Send + Sync` must stop the build here rather than as an unsatisfied bound two crates away
1467/// — the reasoning `FileAccess`'s own `assert_shareable` block gives.
1468const _: () = {
1469    const fn assert_shareable<T: Send + Sync>() {}
1470    assert_shareable::<BuiltinProvider>();
1471};
1472
1473#[cfg(test)]
1474mod tests {
1475    use lanekeep_lang::Language as _;
1476    use lanekeep_lang_js::{Tsx, TypeScript};
1477
1478    use super::{
1479        AnalysisBudget, BuiltinProvider, FileAccess, FilePath, Query, Type, TypeProvider,
1480        extension_is_tsx,
1481    };
1482    use crate::types::Primitive;
1483
1484    /// Parse `source` with the TypeScript grammar, for building a `Query` by hand.
1485    ///
1486    /// A local copy of `tests/provider.rs`'s helper of the same name: that one is compiled
1487    /// into a separate integration-test binary and cannot be reached from a unit test, which
1488    /// is exactly what the parse-count seam below needs — it is a private field.
1489    fn parse(source: &str) -> tree_sitter::Tree {
1490        let mut parser = tree_sitter::Parser::new();
1491        parser
1492            .set_language(&TypeScript.grammar())
1493            .expect("the TypeScript grammar loads");
1494        parser.parse(source, None).expect("the source parses")
1495    }
1496
1497    /// The last node of `kind` in the tree, in source order — a use rather than a declaration.
1498    fn last_of<'t>(tree: &'t tree_sitter::Tree, kind: &str) -> tree_sitter::Node<'t> {
1499        let mut best: Option<tree_sitter::Node<'t>> = None;
1500        let mut stack = vec![tree.root_node()];
1501        while let Some(node) = stack.pop() {
1502            if node.kind() == kind && best.is_none_or(|b| node.start_byte() > b.start_byte()) {
1503                best = Some(node);
1504            }
1505            let mut cursor = node.walk();
1506            let children: Vec<tree_sitter::Node<'t>> = node.children(&mut cursor).collect();
1507            stack.extend(children);
1508        }
1509        best.unwrap_or_else(|| panic!("no `{kind}` node in the tree"))
1510    }
1511
1512    /// A budget generous enough that nothing here can breach it.
1513    fn budget() -> AnalysisBudget {
1514        AnalysisBudget::start(std::time::Duration::from_mins(10))
1515    }
1516
1517    /// The tsx grammar is a second parser with a second identity, and the provider's own
1518    /// identity is what a cache key folds — so a run whose provider can read `.tsx` must not
1519    /// share one with a run whose provider cannot.
1520    #[test]
1521    fn a_tsx_parser_moves_the_provider_identity() {
1522        let without = BuiltinProvider::probe(&TypeScript).expect("TypeScript");
1523        let with =
1524            BuiltinProvider::probe_with(&TypeScript, Some(&Tsx)).expect("TypeScript and tsx");
1525        assert_ne!(
1526            without.identity(),
1527            with.identity(),
1528            "the tsx grammar's identity is part of the provider's"
1529        );
1530    }
1531
1532    /// Which grammar parses `.ts` is part of the key, not only whether a second one exists:
1533    /// `TypeScript` and `Tsx` share one `analysis_identity`, so folding that alone let a
1534    /// provider over the TSX grammar warm the cache of one over the TypeScript grammar.
1535    #[test]
1536    fn the_main_grammar_moves_the_provider_identity() {
1537        let over_typescript =
1538            BuiltinProvider::probe_with(&TypeScript, Some(&Tsx)).expect("TypeScript and tsx");
1539        let over_tsx = BuiltinProvider::probe_with(&Tsx, Some(&Tsx)).expect("tsx twice");
1540        assert_ne!(
1541            over_typescript.identity(),
1542            over_tsx.identity(),
1543            "two main grammars over one tsx grammar are two providers"
1544        );
1545    }
1546
1547    /// The fold, byte for byte: a test that only asserts inequality is satisfied by the
1548    /// vectors' lengths alone, and survived a fold that pushed zeros for the tsx digest.
1549    #[test]
1550    fn the_identity_folds_both_grammar_digests_and_the_resolver() {
1551        let with =
1552            BuiltinProvider::probe_with(&TypeScript, Some(&Tsx)).expect("TypeScript and tsx");
1553        let expected = [
1554            &b"builtin:"[..],
1555            &lanekeep_lang::grammar_digest(&TypeScript.grammar()),
1556            &[1],
1557            &lanekeep_lang::grammar_digest(&Tsx.grammar()),
1558            &TypeScript.analysis_identity(),
1559            &crate::oracle_identity(),
1560        ]
1561        .concat();
1562        assert_eq!(with.identity(), expected);
1563        let without = BuiltinProvider::probe(&TypeScript).expect("TypeScript");
1564        let expected = [
1565            &b"builtin:"[..],
1566            &lanekeep_lang::grammar_digest(&TypeScript.grammar()),
1567            &TypeScript.analysis_identity(),
1568            &crate::oracle_identity(),
1569        ]
1570        .concat();
1571        assert_eq!(without.identity(), expected);
1572    }
1573
1574    /// The parser selector agrees with the registry about what a `.tsx` path is — the
1575    /// extension, case-insensitively, and nothing else about the name.
1576    #[test]
1577    fn a_tsx_extension_is_the_last_component_dot_tsx() {
1578        assert!(extension_is_tsx("src/Button.tsx"));
1579        assert!(extension_is_tsx("node_modules/w/src/Button.TSX"));
1580        assert!(!extension_is_tsx("src/Button.ts"));
1581        assert!(!extension_is_tsx("src/v1.2/Button"));
1582        assert!(
1583            !extension_is_tsx("src/.tsx"),
1584            "a hidden file has no extension"
1585        );
1586        assert!(!extension_is_tsx(".tsx"), "nor does one at the root");
1587        assert!(
1588            !extension_is_tsx("Button.tsx/index"),
1589            "the extension is the last component's"
1590        );
1591    }
1592
1593    /// The mirror of `a_path_that_was_absent_is_parsed_once_it_becomes_text`: a path that has
1594    /// stopped answering does not keep its parse.
1595    ///
1596    /// A unit test rather than an integration one because the only thing it can observe is the
1597    /// size of a private map — the *answer* is `None` either way, which is exactly why holding
1598    /// the entry was invisible. What it costs is a whole declaration file's tree and source
1599    /// held until the next `begin_run`, on a path nothing can ever be served from again.
1600    #[test]
1601    fn a_declaration_whose_file_vanished_is_dropped() {
1602        let dir =
1603            std::env::temp_dir().join(format!("lanekeep-builtin-vanished-{}", std::process::id()));
1604        let _ = std::fs::remove_dir_all(&dir);
1605        std::fs::create_dir_all(&dir).expect("creates the project directory");
1606        std::fs::write(dir.join("lib.d.ts"), "export declare class Big {}\n")
1607            .expect("writes the declaration file");
1608
1609        let provider = BuiltinProvider::probe(&TypeScript).expect("TypeScript");
1610        let path = FilePath::new("lib.d.ts");
1611        assert!(
1612            provider
1613                .declaration(&FileAccess::new(&dir), &path)
1614                .is_some(),
1615            "it is there and it parses"
1616        );
1617        assert_eq!(provider.declarations().len(), 1, "so it is held");
1618
1619        std::fs::remove_file(dir.join("lib.d.ts")).expect("removes the declaration file");
1620        assert!(
1621            provider
1622                .declaration(&FileAccess::new(&dir), &path)
1623                .is_none(),
1624            "nothing is there now"
1625        );
1626        assert_eq!(
1627            provider.declarations().len(),
1628            0,
1629            "and the parse it can no longer serve is not held either"
1630        );
1631
1632        let _ = std::fs::remove_dir_all(&dir);
1633    }
1634
1635    /// `revalidate` drops only the entry whose bytes moved, and clears completeness wholesale.
1636    ///
1637    /// Two declaration files are parsed and memoized; one is rewritten between calls. The
1638    /// changed entry is dropped — a stale parse must not be served again — and the unchanged
1639    /// one is kept, which is the whole point of holding a provider across requests (#191):
1640    /// revalidation that dropped everything would cost exactly what never holding it at all
1641    /// costs.
1642    #[test]
1643    fn revalidate_drops_only_the_rewritten_declaration() {
1644        let dir = std::env::temp_dir().join(format!(
1645            "lanekeep-builtin-revalidate-{}",
1646            std::process::id()
1647        ));
1648        let _ = std::fs::remove_dir_all(&dir);
1649        std::fs::create_dir_all(&dir).expect("creates the project directory");
1650        std::fs::write(
1651            dir.join("stable.d.ts"),
1652            "export declare const rate: number;\n",
1653        )
1654        .expect("writes the stable declaration file");
1655        std::fs::write(
1656            dir.join("moved.d.ts"),
1657            "export declare const rate: number;\n",
1658        )
1659        .expect("writes the declaration file that will move");
1660
1661        let provider = BuiltinProvider::probe(&TypeScript).expect("TypeScript");
1662        let files = FileAccess::new(&dir);
1663        let stable = FilePath::new("stable.d.ts");
1664        let moved = FilePath::new("moved.d.ts");
1665        assert!(provider.declaration(&files, &stable).is_some());
1666        assert!(provider.declaration(&files, &moved).is_some());
1667        assert_eq!(provider.declarations().len(), 2, "both are held");
1668        // A file completeness would have been decided over, so the clearing this test also
1669        // asserts has something in it to clear.
1670        provider
1671            .completeness()
1672            .insert(FilePath::new("src/a.ts"), true);
1673
1674        std::fs::write(
1675            dir.join("moved.d.ts"),
1676            "export declare const rate: string;\n",
1677        )
1678        .expect("rewrites the declaration file");
1679        provider.revalidate(&FileAccess::new(&dir));
1680
1681        assert_eq!(
1682            provider.declarations().len(),
1683            1,
1684            "the rewritten entry is dropped, the unchanged one is not"
1685        );
1686        assert!(
1687            provider.declarations().contains_key(&stable),
1688            "the file whose bytes did not move is still held"
1689        );
1690        assert!(
1691            !provider.declarations().contains_key(&moved),
1692            "the file whose bytes moved is not"
1693        );
1694        assert!(
1695            provider.completeness().is_empty(),
1696            "completeness carries no hash to compare against, so it is simply forgotten"
1697        );
1698
1699        let _ = std::fs::remove_dir_all(&dir);
1700    }
1701
1702    /// The point of holding a provider: `begin_run` no longer throws its parses away, and a
1703    /// held declaration answers across two runs without being read from disk a second time —
1704    /// but a rewrite between them is still caught, because `revalidate` is what a session
1705    /// calls to catch it.
1706    #[test]
1707    fn a_held_declaration_survives_begin_run_and_is_reparsed_after_a_revalidated_rewrite() {
1708        let dir = std::env::temp_dir().join(format!(
1709            "lanekeep-builtin-parse-once-{}",
1710            std::process::id()
1711        ));
1712        let _ = std::fs::remove_dir_all(&dir);
1713        std::fs::create_dir_all(&dir).expect("creates the project directory");
1714        std::fs::write(
1715            dir.join("money.d.ts"),
1716            "export declare const rate: number;\n",
1717        )
1718        .expect("writes the declaration file");
1719
1720        let provider = BuiltinProvider::probe(&TypeScript).expect("TypeScript");
1721        let subject = "import { rate } from './money';\nconst y = rate;\n";
1722        let tree = parse(subject);
1723        let file = FilePath::new("a.ts");
1724        let node = last_of(&tree, "identifier");
1725
1726        // Each "request" below builds its own `FileAccess`, exactly as `SessionProvider` does
1727        // per request in `crates/lanekeep-cli/src/session.rs` — a `FileAccess` memoizes the
1728        // hashes it reads for its own lifetime, so reusing one across requests would hide a
1729        // rewrite behind that memo rather than testing what `begin_run`/`revalidate` do.
1730        let request_one = FileAccess::new(&dir);
1731        assert_eq!(
1732            provider.type_of(Query {
1733                file: &file,
1734                tree: &tree,
1735                source: subject,
1736                node,
1737                files: &request_one,
1738            }),
1739            Some(Type::Primitive(Primitive::Number)),
1740            "the first request reads and parses the declaration file"
1741        );
1742        assert_eq!(provider.parses(), 1, "one read, one parse");
1743
1744        provider
1745            .begin_run(&Vec::new, budget())
1746            .expect("a second run begins");
1747        let request_two = FileAccess::new(&dir);
1748        assert_eq!(
1749            provider.type_of(Query {
1750                file: &file,
1751                tree: &tree,
1752                source: subject,
1753                node,
1754                files: &request_two,
1755            }),
1756            Some(Type::Primitive(Primitive::Number)),
1757            "still answers across the run boundary"
1758        );
1759        assert_eq!(
1760            provider.parses(),
1761            1,
1762            "the declaration is held across `begin_run` now — its bytes did not move, so it \
1763             is not parsed again"
1764        );
1765
1766        std::fs::write(
1767            dir.join("money.d.ts"),
1768            "export declare const rate: string;\n",
1769        )
1770        .expect("rewrites the declaration file");
1771        let request_three = FileAccess::new(&dir);
1772        provider.revalidate(&request_three);
1773        provider
1774            .begin_run(&Vec::new, budget())
1775            .expect("a third run begins");
1776        assert_eq!(
1777            provider.type_of(Query {
1778                file: &file,
1779                tree: &tree,
1780                source: subject,
1781                node,
1782                files: &request_three,
1783            }),
1784            Some(Type::Primitive(Primitive::String)),
1785            "revalidate dropped the stale entry, so the rewrite is seen"
1786        );
1787        assert_eq!(
1788            provider.parses(),
1789            2,
1790            "the rewritten file is re-parsed exactly once, on the request that revalidated it"
1791        );
1792
1793        let _ = std::fs::remove_dir_all(&dir);
1794    }
1795}