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