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}