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}