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