lanekeep_types/provider.rs
1//! The seam every type answer crosses.
2//!
3//! [`TypeScriptOracle`](crate::TypeScriptOracle) borrows one tree for its whole life, which
4//! is the right shape for a within-file oracle and the wrong one for anything
5//! whole-program: a provider backed by a compiler holds state the *run* owns and cannot hand
6//! back a value borrowing a single parse. So the boundary is a trait whose methods borrow
7//! nothing beyond the call, and everything a question needs travels in one [`Query`].
8//!
9//! Every arm may answer `None`, and `None` is this crate's first-class "I could not be sure"
10//! rather than a failure — see the crate documentation. A provider that guessed would
11//! produce a rule that accuses correct code, which is the one failure the whole surface is
12//! arranged against.
13
14use std::collections::BTreeSet;
15
16use lanekeep_core::files::FileAccess;
17use lanekeep_core::{AnalysisBudget, FilePath};
18
19use crate::types::{Symbol, Type};
20
21/// One question, with everything answering it needs.
22///
23/// `Copy`, so an arm can hand the same question to a helper without a clone and without
24/// borrowing itself into a corner.
25#[derive(Debug, Clone, Copy)]
26pub struct Query<'a> {
27 /// The file the question is about, relative to the project root.
28 ///
29 /// A relative specifier resolves against this, so it is load-bearing rather than
30 /// informational: the same import written in two directories names two files.
31 pub file: &'a FilePath,
32 /// That file's parse.
33 pub tree: &'a tree_sitter::Tree,
34 /// That file's source, which every byte range in `tree` indexes.
35 pub source: &'a str,
36 /// The node asked about. The file's root for [`TypeProvider::complete`], which asks
37 /// about the whole file rather than about a position in it.
38 pub node: tree_sitter::Node<'a>,
39 /// Tracked, confined access to the rest of the project.
40 ///
41 /// Every file a provider opens goes through this, hit or miss, so a declaration an
42 /// answer depended on — including one that was **not** there — invalidates the asking
43 /// file's cache entry when it appears, changes or vanishes. A provider reading the
44 /// filesystem any other way would compute an answer no cache key covers.
45 pub files: &'a FileAccess,
46}
47
48/// What answers `ctx.types`.
49///
50/// One implementation per strategy — the bounded builtin oracle here, a `tsc` sidecar in
51/// A3 — and the engine holds exactly one for a run. [`Self::identity`] is why the two cannot
52/// be confused by a cache: it is a key input, so a result computed by one is never served to
53/// a run using the other.
54pub trait TypeProvider: Send + Sync {
55 /// The type of the expression at `q.node`.
56 fn type_of(&self, q: Query<'_>) -> Option<Type>;
57
58 /// Where the name at `q.node` came from.
59 fn symbol_of(&self, q: Query<'_>) -> Option<Symbol>;
60
61 /// What calling the function at `q.node` yields.
62 ///
63 /// Separate from [`Self::type_of`] because a function declaration is not an expression,
64 /// and giving `type_of` a signature type would invent a variant every rule would then
65 /// have to unpack. Accepts a call expression, a function-like declaration, or an
66 /// identifier bound to one.
67 ///
68 /// A generic call's return may or may not be instantiated from its arguments, depending on
69 /// the provider: the `tsc` provider answers the instantiated type — `useMemo(() => 0n, [])`
70 /// is `bigint` — while the builtin oracle answers `None`, doing no call-site inference. The
71 /// common divergence is exactly that: `tsc` names a type where the builtin is silent, and
72 /// silence is spelled `None`, the "could not be sure" value every rule already handles. The
73 /// two can also give *different* present answers for an *overloaded* call — the builtin
74 /// reads the first declared overload, `tsc` the one the arguments select — a narrow case no
75 /// shipped rule asks about, and one where `tsc`'s answer is the more precise of the two.
76 fn return_type_of(&self, q: Query<'_>) -> Option<Type>;
77
78 /// Whether the type at `q.node` is the type `module` exports as `name`, or declares a
79 /// relationship to it — `extends` or `implements` — across files, through aliases of the
80 /// named type.
81 ///
82 /// Nominal, never structural. `Some(false)` is a real answer — the walk completed and
83 /// found nothing — and `None` is "a link in the chain could not be read", which a rule
84 /// must not treat as a negative. A union is assignable only when every member is.
85 ///
86 /// Three narrowings of what "found nothing" honestly covers: declaration merging is not
87 /// followed, so when a name is declared more than once at a file's top level only the
88 /// first declaration is consulted; a generic annotation at the use site (`let x:
89 /// Box<number>`) answers `None`, since type arguments are not read; and a `name` the
90 /// named `module` does not export answers `None` rather than `Some(false)`, because
91 /// `Some(false)` there would make a requirement rule report on every value the module
92 /// never claimed to type.
93 ///
94 /// One documented gap: declarations are looked up at a file's top level only, so a
95 /// declaration that shadows the target's name inside a function body is not
96 /// distinguished from the top-level one with that name — the walk answers as if the
97 /// shadow were the top-level declaration.
98 fn is_assignable_to(&self, q: Query<'_>, module: &str, name: &str) -> Option<bool>;
99
100 /// Whether every import in `q`'s file resolved to something this provider could read.
101 ///
102 /// `false` is the honest label on a partial answer: a rule that reports on an absent
103 /// type would accuse code the provider never saw. Takes a whole [`Query`] rather than a
104 /// path because the question is about the file's *imports*, which cannot be enumerated
105 /// without its tree.
106 ///
107 /// **The verdict is about reading, per declaration where one is reached.** A parse fault
108 /// anywhere in a resolved declaration file used to mark every importer of it incomplete —
109 /// one unparsed construct in a fifty-thousand-line `@types` bundle, poisoning the whole
110 /// project. The builtin provider judges a named import by the node its name reaches: a
111 /// fault inside that node — an `ERROR`, or the `MISSING` token an unclosed brace leaves —
112 /// makes the file incomplete, one elsewhere does not, and the declarations outside the
113 /// damaged span answer normally. A name whose module resolved and parsed cleanly but which
114 /// the provider cannot follow to a declaration is *not* incomplete: `complete` says whether
115 /// every import was read, never whether every name is typeable, and `export = X` beside
116 /// `declare namespace X` is a module read whole whose members answer `undefined`. A
117 /// nameless import — side-effect, namespace, `export *` — has no single node to reach and
118 /// keeps the whole-file verdict. Silence is still the safe direction: a link that cannot
119 /// be read, wherever it sits, is `false` rather than a guess. A provider that delegates to
120 /// `tsc` answers whatever its driver reports for the file as a whole.
121 fn complete(&self, q: Query<'_>) -> bool;
122
123 /// What this provider *is*, for the cache key.
124 ///
125 /// Folded into `analysis_hash` by the engine. Two providers that would answer
126 /// differently must return different bytes, and a provider whose own code changed must
127 /// too — the builtin one derives it from `oracle_identity`, which digests this crate's
128 /// `src/`, for exactly that reason.
129 fn identity(&self) -> Vec<u8>;
130
131 /// Every path this provider's own dependency mechanism named for the run it last prepared.
132 ///
133 /// `--watch`'s allowlist unions this with `Outcome::dependency_paths`, because the two
134 /// cover different providers' dependencies and neither can stand in for the other. A
135 /// provider whose reads travel through [`Query::files`] — the builtin oracle — already
136 /// lands in `Outcome::dependency_paths` as per-file tracked reads, so its default here is
137 /// empty rather than a duplicate of what the engine already collects. The `tsc` provider
138 /// overrides it: the compiler reads through its own host, never through `Query::files`, so
139 /// nothing it consults is a tracked read on any file, and without this override an edit to
140 /// a linked package's declaration file would wake `--watch` for nothing at all.
141 ///
142 /// Answered after [`Self::begin_run`], from whatever it last built — a provider held across
143 /// runs (plan 6) reports the run it most recently prepared, not the run before it.
144 ///
145 /// The default is empty, matching every provider that has no dependency mechanism outside
146 /// tracked reads.
147 fn dependency_paths(&self) -> BTreeSet<FilePath> {
148 BTreeSet::new()
149 }
150
151 /// Drop whatever this provider holds that the files no longer support.
152 ///
153 /// Called once per request by a session that holds this provider across several of them
154 /// (`crates/lanekeep-cli/src/session.rs`). A one-shot run builds a provider and throws it
155 /// away, so for that path this is a no-op that costs a virtual call.
156 ///
157 /// The default body is empty because a provider with no cache has nothing to revalidate,
158 /// and requiring every implementor to write that down would be noise. The two that do
159 /// hold something override it: the builtin provider re-hashes each declaration it parsed
160 /// and forgets its memoized completeness, and the `tsc` provider does nothing here because
161 /// `begin_run` already re-answers `programs` on every prepare, held provider or not.
162 /// **Nothing here reads an mtime** — held state is keyed by content hash, which is the
163 /// only reason it is allowed to be held at all (architecture §8.2).
164 fn revalidate(&self, files: &FileAccess) {
165 let _ = files;
166 }
167
168 /// Prepare for a run over the corpus `files` yields, and answer the per-run key term.
169 ///
170 /// Called by the engine once per run, after discovery and before the run key is
171 /// computed, for a fresh provider and a held one alike (plan 6). The default is nothing
172 /// to build and no term: this provider's dependencies are tracked reads on each entry.
173 /// The `tsc` provider (plan 5) overrides it to build every program the run's files
174 /// belong to and answer the hash over their file lists and contents — that provider's
175 /// whole dependency mechanism (spec §5.6) — which is why the term is asked for through
176 /// the trait rather than read off a concrete type the engine would have to downcast to.
177 /// The run's [`AnalysisBudget`] is the second parameter: a provider that spends wall
178 /// clock time preparing must spend the *run's* budget rather than one of its own, so a
179 /// breach here is the same breach `timeouts.analysis` names everywhere else. The default
180 /// body ignores it for the same reason it ignores the file list — it does no work.
181 ///
182 /// **The list is a closure, and the default body never calls it.** Producing it costs the
183 /// engine a second walk of the whole project, on the warm path, for a provider that may
184 /// have no use for it — which is what the only provider that ships today does. A `&[…]`
185 /// parameter makes that walk unconditional; a `&dyn Fn` makes it the caller's cost only
186 /// when a provider asks.
187 ///
188 /// What the closure yields is the corpus **as discovered**, not the run's `--since` or
189 /// `--staged` selection: a program is a property of the project, and building one from a
190 /// changed-files subset would answer a different question on a warm run than on a cold
191 /// one.
192 ///
193 /// # Errors
194 ///
195 /// [`BeginRunError::Timeout`] when the work outlived its budget and
196 /// [`BeginRunError::Failed`] for anything else. Both cancel the run, and the engine takes
197 /// a different exit for each: `RunError::AnalysisTimeout`, which names `timeouts.analysis`,
198 /// and `RunError::Provider`, which names the toolchain.
199 fn begin_run(
200 &self,
201 files: &dyn Fn() -> Vec<FilePath>,
202 budget: AnalysisBudget,
203 ) -> Result<Vec<u8>, BeginRunError> {
204 let _ = (files, budget);
205 Ok(Vec::new())
206 }
207
208 /// The first error this provider produced, if it has produced one.
209 ///
210 /// **Why this is on the trait at all.** Every arm above answers "I don't know" — `None`,
211 /// or `false` from [`Self::complete`] — when the provider is broken, because the trait has
212 /// nowhere to put an error. So a killed sidecar is indistinguishable, at this boundary,
213 /// from a compiler that simply has no type for that node: the file finishes *degraded* and
214 /// is committed under a valid cache key, which is a limit degrading a run instead of
215 /// cancelling it. This is the one way a caller can tell the two apart, so the engine asks
216 /// after every file and cancels the run when it is `Some`.
217 ///
218 /// It is answered through the trait rather than off a concrete type because the engine
219 /// holds an `Arc<dyn TypeProvider>` — a session's held provider (plan 6) included — and
220 /// the alternative is a downcast, which is the door plan 4 closed for the run key.
221 ///
222 /// [`BeginRunError`] rather than an enum of its own: its two arms are exactly the two
223 /// exits the engine has for a provider — a spent `timeouts.analysis`, and everything else
224 /// — and a second type with the same two arms would be two spellings of one decision.
225 ///
226 /// The default is `None`: a provider with no out-of-band state has nothing to report, and
227 /// the builtin one has no analogous failure.
228 fn failure(&self) -> Option<BeginRunError> {
229 None
230 }
231
232 /// Lines this provider wants said about the run it has just prepared.
233 ///
234 /// Asked once, after [`Self::begin_run`], and printed by the CLI on **stderr** — never on
235 /// stdout, which carries a run's report and is what a machine reads. The engine prints
236 /// nothing itself; it exposes these and the caller decides.
237 ///
238 /// What they are for is a decision a provider made silently that changes its answers. The
239 /// `tsc` provider's is the ad-hoc program: a file no `tsconfig.json` *under the project
240 /// root* claims is typed with this driver's own options rather than the project's, so
241 /// `strict` is off for it and the same file checked from one directory up answers
242 /// differently. That is not a failure — nothing is wrong and the run is correct — so it
243 /// cannot be an error, and it is not nothing either.
244 ///
245 /// The default is empty: a provider that made no such decision has nothing to say, and a
246 /// notice printed by every run is noise rather than information.
247 fn notices(&self) -> Vec<String> {
248 Vec::new()
249 }
250
251 /// Whether this provider is past repair and has to be built again.
252 ///
253 /// Asked by a session that holds a provider across requests
254 /// (`crates/lanekeep-cli/src/session.rs`), before it hands the held one back. It is the
255 /// difference between state that is *stale* and state that is *gone*: [`Self::revalidate`]
256 /// repairs the first, and nothing repairs the second.
257 ///
258 /// The `tsc` provider is the one that answers `true`: a breached `timeouts.analysis` kills
259 /// its sidecar, and nothing respawns it, so every later request in that session failed with
260 /// "the sidecar exited without answering" for the life of the editor — one slow build
261 /// bricking the session, where `lanekeep check` over the same project spawns a sidecar and
262 /// succeeds. A limit must cancel the run it breached and nothing after it.
263 ///
264 /// The default is `false`: a provider whose state is ordinary memory cannot be gone while
265 /// the process that holds it is running.
266 fn needs_rebuild(&self) -> bool {
267 false
268 }
269
270 /// Whether this provider spends the run's [`AnalysisBudget`].
271 ///
272 /// The engine reads the accumulator between one file and the next and cancels the run when
273 /// it is past `timeouts.analysis`; this is what says whether that question is worth asking
274 /// at all. It used to be asked of the *configuration* — `types.provider == 'tsc'` — which
275 /// is the wrong thing twice over: a session that hands the engine a provider of its own
276 /// (#191) is not described by the config it was built from, and a builtin run whose config
277 /// happened to say `tsc` would be bounded by a budget that names work it never does.
278 ///
279 /// The default is `false`, which is the builtin oracle's answer: its cost is rule
280 /// execution, and the run clock already bounds that.
281 fn spends_analysis_budget(&self) -> bool {
282 false
283 }
284}
285
286/// Why a provider could not do its work — preparing a run in [`TypeProvider::begin_run`], or
287/// answering a question afterwards, which [`TypeProvider::failure`] reports in the same shape.
288///
289/// The two arms are the engine's two exits: `Timeout` is `RunError::AnalysisTimeout`, which
290/// names `timeouts.analysis`, and `Failed` is `RunError::Provider`, which names the toolchain.
291/// Telling them apart matters because the remedies are different, and printing the wrong one
292/// is advice that cannot work.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum BeginRunError {
295 /// The provider's work outlived the analysis budget.
296 Timeout(String),
297 /// The provider could not do its work at all.
298 Failed(String),
299}
300
301#[cfg(test)]
302mod tests {
303 use std::sync::atomic::{AtomicUsize, Ordering};
304
305 use super::*;
306
307 /// A provider that answers nothing and counts revalidations.
308 ///
309 /// Nothing here needs a parsed tree: the property under test is that the call reaches an
310 /// implementor through the trait object a session holds, which is exactly what a
311 /// concrete-typed test could not establish.
312 #[derive(Default)]
313 struct Counting(AtomicUsize);
314
315 impl TypeProvider for Counting {
316 fn type_of(&self, _q: Query<'_>) -> Option<Type> {
317 None
318 }
319 fn symbol_of(&self, _q: Query<'_>) -> Option<Symbol> {
320 None
321 }
322 fn return_type_of(&self, _q: Query<'_>) -> Option<Type> {
323 None
324 }
325 fn is_assignable_to(&self, _q: Query<'_>, _module: &str, _name: &str) -> Option<bool> {
326 None
327 }
328 fn complete(&self, _q: Query<'_>) -> bool {
329 false
330 }
331 fn identity(&self) -> Vec<u8> {
332 Vec::new()
333 }
334 fn revalidate(&self, _files: &FileAccess) {
335 self.0.fetch_add(1, Ordering::Relaxed);
336 }
337 }
338
339 /// A provider that overrides nothing, to prove the default body exists.
340 #[derive(Default)]
341 struct Silent;
342
343 impl TypeProvider for Silent {
344 fn type_of(&self, _q: Query<'_>) -> Option<Type> {
345 None
346 }
347 fn symbol_of(&self, _q: Query<'_>) -> Option<Symbol> {
348 None
349 }
350 fn return_type_of(&self, _q: Query<'_>) -> Option<Type> {
351 None
352 }
353 fn is_assignable_to(&self, _q: Query<'_>, _module: &str, _name: &str) -> Option<bool> {
354 None
355 }
356 fn complete(&self, _q: Query<'_>) -> bool {
357 false
358 }
359 fn identity(&self) -> Vec<u8> {
360 Vec::new()
361 }
362 }
363
364 #[test]
365 fn revalidate_reaches_an_implementor_through_the_trait_object() {
366 let counting = std::sync::Arc::new(Counting::default());
367 let held: std::sync::Arc<dyn TypeProvider> = counting.clone();
368 let files = FileAccess::new(std::path::Path::new("."));
369
370 held.revalidate(&files);
371 held.revalidate(&files);
372
373 assert_eq!(
374 counting.0.load(Ordering::Relaxed),
375 2,
376 "a session revalidates once per request, through the trait object it holds"
377 );
378 }
379
380 #[test]
381 fn a_provider_that_holds_nothing_need_not_override_revalidate() {
382 // The default body is what keeps every other implementor compiling — a provider with
383 // no cache has nothing to drop, and requiring it to say so would be noise.
384 let held: std::sync::Arc<dyn TypeProvider> = std::sync::Arc::new(Silent);
385 held.revalidate(&FileAccess::new(std::path::Path::new(".")));
386 }
387}