lanekeep_lang/lib.rs
1//! Language trait and registry for lanekeep.
2//!
3//! The `Language` trait and the registry mapping file extensions onto grammars.
4//!
5//! It also owns `binding`: the shape of a resolved binding, and the convention deciding
6//! which import a rule's `resolvesToImport`/`isImportedFrom` counts as a match. That
7//! convention lives here rather than in either rule-execution engine because both call
8//! it, and two copies would drift into answering plausibly and differently for the same
9//! file — which no test on either side would catch.
10//!
11//! This abstraction exists before it has a second implementor on purpose. Retrofitting it
12//! after a second language arrives is the expensive version of the same work.
13
14pub mod binding;
15pub mod flow;
16pub mod grammar;
17pub mod obligation;
18
19pub use flow::{FlowAnalyzer, FlowPath};
20pub use grammar::grammar_digest;
21pub use obligation::{ObligationAnalyzer, ObligationScope, UnmetObligation};
22
23use std::collections::BTreeMap;
24use std::fmt;
25use std::path::Path;
26use std::sync::Arc;
27
28use thiserror::Error;
29
30/// A language's stable identifier, as written in a rule's `language` field.
31///
32/// Deliberately not an enum. An enum would have to live in this crate and name every
33/// language, so adding one would mean editing the abstraction rather than adding an
34/// implementor — exactly the coupling the trait exists to avoid.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub struct LanguageId(&'static str);
37
38impl LanguageId {
39 /// Declare an identifier. Called by language implementations.
40 #[must_use]
41 pub const fn new(id: &'static str) -> Self {
42 Self(id)
43 }
44
45 /// The identifier as it appears in configuration.
46 #[must_use]
47 pub const fn as_str(self) -> &'static str {
48 self.0
49 }
50}
51
52impl fmt::Display for LanguageId {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 f.write_str(self.0)
55 }
56}
57
58/// A language lanekeep can parse.
59///
60/// `Send + Sync` because the walker runs files across rayon workers, and every worker needs
61/// the grammar.
62///
63/// Binding resolution — the light semantic layer behind the import-resolution host
64/// functions — is deliberately not a method here yet. Its signature depends on the tree
65/// and file context types, which do not exist. Adding a method to a trait with no external
66/// implementors is cheap; committing to a half-designed signature is not.
67pub trait Language: Send + Sync {
68 /// Stable identifier, as written in a rule's `language` field.
69 fn id(&self) -> LanguageId;
70
71 /// Identifier resolution for this language, when it has any.
72 ///
73 /// Returns `None` for a language with no resolver yet, which is honest rather than a
74 /// placeholder: a rule asking about bindings in such a language gets nothing back
75 /// instead of a confidently wrong answer.
76 fn resolver(&self) -> Option<Arc<dyn binding::BindingResolver>> {
77 None
78 }
79
80 /// The typestate/obligation analysis for this language, when it has one.
81 ///
82 /// Returns `None` for a language with no obligation analyzer yet, which is honest
83 /// rather than a placeholder: a rule asking about obligations in such a language gets
84 /// nothing back instead of a confidently wrong answer.
85 fn obligation_analyzer(&self) -> Option<Arc<dyn ObligationAnalyzer>> {
86 None
87 }
88
89 /// The taint/data-flow analysis for this language, when it has one.
90 ///
91 /// Returns `None` for a language with no flow analyzer yet, which is honest rather than a
92 /// placeholder: a rule asking about flows in such a language gets nothing back instead of
93 /// a confidently wrong answer.
94 fn flow_analyzer(&self) -> Option<Arc<dyn FlowAnalyzer>> {
95 None
96 }
97
98 /// Extensions this language claims, without the leading dot, lowercase.
99 ///
100 /// Two languages must not claim the same extension; the registry rejects that at
101 /// registration rather than picking a winner.
102 fn extensions(&self) -> &'static [&'static str];
103
104 /// The tree-sitter grammar.
105 fn grammar(&self) -> tree_sitter::Language;
106
107 /// The grammar's ABI version.
108 ///
109 /// **No longer the cache key's term for the grammar**, and the doc said otherwise for a
110 /// while. The key folds [`grammar::grammar_digest`], which reads `abi_version()` off the
111 /// grammar itself along with the node kinds and fields — so the ABI still reaches the key,
112 /// through that digest rather than through this accessor. A grammar bump changes node
113 /// shapes and therefore query results, and the digest is what catches it.
114 ///
115 /// Kept because it is published API and answers a question callers legitimately ask.
116 ///
117 /// Read from the grammar rather than written down, or it stops tracking the thing it
118 /// exists to track the first time someone forgets to update it. Note that bundled
119 /// grammars do not share an ABI — TypeScript and JavaScript currently differ — which
120 /// is why this is per-language rather than one global constant.
121 fn grammar_abi(&self) -> usize {
122 self.grammar().abi_version()
123 }
124
125 /// What this language's own analysis code *is*, as a digest of the sources that decide
126 /// an answer.
127 ///
128 /// A cache key input, and a different question from [`grammar::grammar_digest`]: that one
129 /// says what the parse tree looks like, this one says what this crate concludes about it.
130 /// A language's [`binding::BindingResolver`] decides where a name was declared, which
131 /// is what `ctx.bindingKind` and `ctx.resolvesToImport` answer with and what the type
132 /// oracle reads — so a result computed by a resolver that no longer exists is not a valid
133 /// result for a run that has a different one.
134 ///
135 /// The gap this closes was not theoretical. `lanekeep_types::oracle_identity` was the
136 /// whole of the key's analysis term, and it digests `crates/lanekeep-types/src/` alone;
137 /// the scope list deciding which nodes carry type parameters lives in
138 /// `lanekeep-lang-js`, and correcting it moved what the oracle answered while every hash
139 /// stayed identical.
140 ///
141 /// Defaulted rather than required, matching [`Self::resolver`] and [`Self::grammar_abi`]:
142 /// this is published API and a required method would break every external implementor.
143 /// The gap that leaves — a language crate with a resolver and no build script — is closed
144 /// by a test in `lanekeep-languages` rather than by the compiler.
145 ///
146 /// Implementors derive this rather than writing it down. See any language crate's
147 /// `build.rs`.
148 fn analysis_identity(&self) -> [u8; 32] {
149 [0; 32]
150 }
151}
152
153/// Why a language could not be registered.
154#[derive(Debug, Clone, PartialEq, Eq, Error)]
155pub enum RegistryError {
156 /// Two languages claim the same identifier.
157 #[error("language `{0}` is already registered")]
158 DuplicateId(String),
159
160 /// Two languages claim the same file extension.
161 #[error(
162 "extension `.{extension}` is claimed by both `{existing}` and `{incoming}`: \
163 a file cannot belong to two languages"
164 )]
165 DuplicateExtension {
166 /// The contested extension.
167 extension: String,
168 /// The language that claimed it first.
169 existing: String,
170 /// The language that tried to claim it second.
171 incoming: String,
172 },
173
174 /// A language declared an extension that cannot match anything.
175 #[error("language `{language}` declared invalid extension `{extension}`: {reason}")]
176 InvalidExtension {
177 /// The language at fault.
178 language: String,
179 /// The extension as declared.
180 extension: String,
181 /// What is wrong with it.
182 reason: &'static str,
183 },
184}
185
186/// Which languages are available, and which files belong to them.
187#[derive(Clone, Default)]
188pub struct LanguageRegistry {
189 by_id: BTreeMap<&'static str, Arc<dyn Language>>,
190 by_extension: BTreeMap<&'static str, Arc<dyn Language>>,
191}
192
193/// Hand-written because `Arc<dyn Language>` is not `Debug` — trait objects would have to
194/// require it, which is a demand on every implementor for the sake of one impl here. The
195/// keys are the useful part anyway: what is registered, and what it claims.
196impl fmt::Debug for LanguageRegistry {
197 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198 f.debug_struct("LanguageRegistry")
199 .field("by_id", &self.by_id.keys().collect::<Vec<_>>())
200 .field(
201 "by_extension",
202 &self.by_extension.keys().collect::<Vec<_>>(),
203 )
204 .finish()
205 }
206}
207
208impl LanguageRegistry {
209 /// An empty registry.
210 #[must_use]
211 pub fn new() -> Self {
212 Self::default()
213 }
214
215 /// Add a language.
216 ///
217 /// # Errors
218 ///
219 /// Fails when the identifier or any extension is already claimed, or when an extension
220 /// is malformed. Rejecting rather than overwriting is deliberate: a registry that
221 /// silently let the last registration win would make which language parses a `.ts`
222 /// file depend on registration order, and that order is not part of any contract.
223 ///
224 /// A failed registration changes nothing — validation of every extension completes
225 /// before any is claimed.
226 pub fn register(&mut self, language: Arc<dyn Language>) -> Result<(), RegistryError> {
227 let id = language.id().as_str();
228
229 if self.by_id.contains_key(id) {
230 return Err(RegistryError::DuplicateId(id.to_owned()));
231 }
232
233 for extension in language.extensions() {
234 let invalid = |reason: &'static str| RegistryError::InvalidExtension {
235 language: id.to_owned(),
236 extension: (*extension).to_owned(),
237 reason,
238 };
239
240 if extension.is_empty() {
241 return Err(invalid("must not be empty"));
242 }
243 if extension.starts_with('.') {
244 return Err(invalid("must not include the leading dot"));
245 }
246 if extension.chars().any(|c| c.is_ascii_uppercase()) {
247 return Err(invalid(
248 "must be lowercase; lookup lowercases the path's extension",
249 ));
250 }
251 if let Some(existing) = self.by_extension.get(extension) {
252 return Err(RegistryError::DuplicateExtension {
253 extension: (*extension).to_owned(),
254 existing: existing.id().as_str().to_owned(),
255 incoming: id.to_owned(),
256 });
257 }
258 }
259
260 for extension in language.extensions() {
261 self.by_extension.insert(extension, Arc::clone(&language));
262 }
263 self.by_id.insert(id, language);
264 Ok(())
265 }
266
267 /// Look up a language by its identifier.
268 #[must_use]
269 pub fn by_id(&self, id: &str) -> Option<&Arc<dyn Language>> {
270 self.by_id.get(id)
271 }
272
273 /// Which language, if any, parses this path.
274 ///
275 /// The extension is lowercased before lookup, so a file named `Button.TSX` is still
276 /// TSX. Without this, whether a file gets checked would depend on how it was typed.
277 #[must_use]
278 pub fn for_path(&self, path: impl AsRef<Path>) -> Option<&Arc<dyn Language>> {
279 let extension = path.as_ref().extension()?.to_str()?.to_ascii_lowercase();
280 self.by_extension.get(extension.as_str())
281 }
282
283 /// Every registered language, ordered by identifier.
284 pub fn languages(&self) -> impl Iterator<Item = &Arc<dyn Language>> {
285 self.by_id.values()
286 }
287
288 /// Every extension any language claims, ordered.
289 pub fn extensions(&self) -> impl Iterator<Item = &'static str> + '_ {
290 self.by_extension.keys().copied()
291 }
292
293 /// How many languages are registered.
294 #[must_use]
295 pub fn len(&self) -> usize {
296 self.by_id.len()
297 }
298
299 /// Whether no language is registered.
300 #[must_use]
301 pub fn is_empty(&self) -> bool {
302 self.by_id.is_empty()
303 }
304}
305
306/// What this crate's own resolution code *is*, as a digest of its sources.
307///
308/// A cache key input, and a separate question from any language's
309/// [`Language::analysis_identity`]. Those cover a language crate's own resolver; this covers the
310/// code every one of them answers *through* — `glob_matches`, [`binding::Binding::is_import_of`]
311/// and [`binding::BindingKind::as_str`] are what `ctx.resolvesToImport` and `ctx.bindingKind`
312/// report with, and editing any of them changes what every language says.
313///
314/// A free function rather than a trait method, because this crate registers no language of its
315/// own. Whoever assembles the key folds it once, beside the type oracle's identity, rather than
316/// per language.
317#[must_use]
318pub fn crate_identity() -> [u8; 32] {
319 // Written by `build.rs`, which walks `src/` so that a file added but not listed cannot be a
320 // silent gap.
321 decode_hex32(env!("LANEKEEP_LANG_ANALYSIS_HASH"))
322}
323
324/// Decode the 64-character lowercase hex a build script emitted into 32 bytes.
325///
326/// Every language crate's `build.rs` writes its digest as hex, because that is what a
327/// `cargo:rustc-env` value can carry. One decoder rather than one per crate: they would be
328/// identical, and a copy that drifts would produce a digest that is stable, wrong, and
329/// indistinguishable from a correct one.
330///
331/// Total rather than fallible. The only inputs are constants this workspace's own build
332/// scripts wrote, so there is no caller input to reject and nothing a caller could do about a
333/// malformed one; a digit outside `0-9a-f` reads as zero, and a string shorter than 64
334/// characters leaves the remaining bytes zero.
335#[must_use]
336pub fn decode_hex32(hex: &str) -> [u8; 32] {
337 let bytes = hex.as_bytes();
338 let mut out = [0_u8; 32];
339 for (index, slot) in out.iter_mut().enumerate() {
340 let hi = index * 2;
341 let lo = hi + 1;
342 if lo >= bytes.len() {
343 break;
344 }
345 *slot = (hex_value(bytes[hi]) << 4) | hex_value(bytes[lo]);
346 }
347 out
348}
349
350/// One lowercase hex digit as a nibble, or zero for anything else.
351const fn hex_value(byte: u8) -> u8 {
352 match byte {
353 b'0'..=b'9' => byte - b'0',
354 b'a'..=b'f' => byte - b'a' + 10,
355 _ => 0,
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 /// A stand-in whose grammar is never exercised. The registry's job is bookkeeping, and
364 /// testing it through a real language would couple these tests to whichever one exists.
365 struct Fake {
366 id: LanguageId,
367 extensions: &'static [&'static str],
368 }
369
370 impl Language for Fake {
371 fn id(&self) -> LanguageId {
372 self.id
373 }
374 fn extensions(&self) -> &'static [&'static str] {
375 self.extensions
376 }
377 fn grammar(&self) -> tree_sitter::Language {
378 unreachable!("registry tests never touch the grammar")
379 }
380 fn grammar_abi(&self) -> usize {
381 0
382 }
383 }
384
385 fn fake(id: &'static str, extensions: &'static [&'static str]) -> Arc<dyn Language> {
386 Arc::new(Fake {
387 id: LanguageId::new(id),
388 extensions,
389 })
390 }
391
392 fn registry(languages: &[Arc<dyn Language>]) -> LanguageRegistry {
393 let mut registry = LanguageRegistry::new();
394 for language in languages {
395 registry.register(Arc::clone(language)).expect("registers");
396 }
397 registry
398 }
399
400 #[test]
401 fn finds_a_language_by_id() {
402 let registry = registry(&[fake("alpha", &["a"])]);
403 assert_eq!(
404 registry.by_id("alpha").expect("present").id().as_str(),
405 "alpha"
406 );
407 assert!(registry.by_id("missing").is_none());
408 }
409
410 #[test]
411 fn finds_a_language_by_path() {
412 let registry = registry(&[fake("alpha", &["a", "aa"]), fake("beta", &["b"])]);
413
414 assert_eq!(
415 registry.for_path("src/x.a").expect("matches").id().as_str(),
416 "alpha"
417 );
418 assert_eq!(
419 registry
420 .for_path("src/x.aa")
421 .expect("matches")
422 .id()
423 .as_str(),
424 "alpha"
425 );
426 assert_eq!(
427 registry.for_path("src/x.b").expect("matches").id().as_str(),
428 "beta"
429 );
430 assert!(registry.for_path("src/x.zzz").is_none());
431 assert!(registry.for_path("src/noextension").is_none());
432 }
433
434 #[test]
435 fn extension_lookup_ignores_case() {
436 // A case-insensitive filesystem lets `Button.TSX` and `Button.tsx` name the same
437 // file. Whether it gets checked must not depend on how it was typed.
438 let registry = registry(&[fake("alpha", &["a"])]);
439 assert!(registry.for_path("src/x.A").is_some());
440 assert!(registry.for_path("src/x.a").is_some());
441 }
442
443 #[test]
444 fn rejects_a_duplicate_id() {
445 let mut registry = registry(&[fake("alpha", &["a"])]);
446 let err = registry
447 .register(fake("alpha", &["z"]))
448 .expect_err("duplicate id");
449 assert_eq!(err, RegistryError::DuplicateId("alpha".to_owned()));
450 }
451
452 #[test]
453 fn rejects_a_contested_extension() {
454 // The important one. Letting the last registration win would make which language
455 // parses a file depend on registration order — an order nothing guarantees, and a
456 // difference that would surface as results changing for no visible reason.
457 let mut registry = registry(&[fake("alpha", &["a"])]);
458 let err = registry
459 .register(fake("beta", &["a"]))
460 .expect_err("contested extension");
461
462 match err {
463 RegistryError::DuplicateExtension {
464 extension,
465 existing,
466 incoming,
467 } => {
468 assert_eq!(extension, "a");
469 assert_eq!(existing, "alpha");
470 assert_eq!(incoming, "beta");
471 }
472 other => panic!("wrong error: {other:?}"),
473 }
474 }
475
476 #[test]
477 fn a_rejected_registration_leaves_no_trace() {
478 // Partial registration would be worse than rejection: the language would be
479 // unreachable by id while still owning whichever extensions were processed before
480 // the conflict.
481 let mut registry = registry(&[fake("alpha", &["a"])]);
482 let _ = registry.register(fake("beta", &["b", "a", "c"]));
483
484 assert!(registry.by_id("beta").is_none());
485 assert!(
486 registry.for_path("x.b").is_none(),
487 "b must not have been claimed"
488 );
489 assert!(
490 registry.for_path("x.c").is_none(),
491 "c must not have been claimed"
492 );
493 assert_eq!(
494 registry.for_path("x.a").expect("still alpha").id().as_str(),
495 "alpha"
496 );
497 assert_eq!(registry.len(), 1);
498 }
499
500 #[test]
501 fn rejects_malformed_extensions() {
502 let mut registry = LanguageRegistry::new();
503
504 // A leading dot would never match, because `Path::extension` strips it.
505 assert!(matches!(
506 registry.register(fake("dotted", &[".a"])),
507 Err(RegistryError::InvalidExtension { .. })
508 ));
509 // Uppercase would never match either, since lookup lowercases first.
510 assert!(matches!(
511 registry.register(fake("shouty", &["A"])),
512 Err(RegistryError::InvalidExtension { .. })
513 ));
514 assert!(matches!(
515 registry.register(fake("empty", &[""])),
516 Err(RegistryError::InvalidExtension { .. })
517 ));
518 assert!(registry.is_empty());
519 }
520
521 #[test]
522 fn iteration_order_is_stable() {
523 // Anything derived from registry order — a `--help` listing, an error naming the
524 // valid languages — must not reorder between runs.
525 let registry = registry(&[
526 fake("zeta", &["z"]),
527 fake("alpha", &["a"]),
528 fake("mu", &["m"]),
529 ]);
530
531 let ids: Vec<&str> = registry.languages().map(|l| l.id().as_str()).collect();
532 assert_eq!(ids, ["alpha", "mu", "zeta"]);
533 assert_eq!(registry.extensions().collect::<Vec<_>>(), ["a", "m", "z"]);
534 }
535
536 #[test]
537 fn an_empty_registry_matches_nothing() {
538 let registry = LanguageRegistry::new();
539 assert!(registry.is_empty());
540 assert_eq!(registry.len(), 0);
541 assert!(registry.for_path("src/x.ts").is_none());
542 assert!(registry.by_id("typescript").is_none());
543 }
544
545 #[test]
546 fn hex_decodes_to_the_bytes_it_spells() {
547 let mut expected = [0_u8; 32];
548 expected[0] = 0x0a;
549 expected[1] = 0xff;
550 expected[31] = 0x10;
551 let hex = format!("0aff{}10", "00".repeat(29));
552 assert_eq!(decode_hex32(&hex), expected);
553 }
554
555 /// A short or malformed string leaves zeros rather than panicking, which is what makes
556 /// this safe to call on a constant no caller supplied.
557 #[test]
558 fn a_malformed_hex_string_decodes_to_zeros() {
559 assert_eq!(decode_hex32(""), [0; 32]);
560 assert_eq!(decode_hex32("zz"), [0; 32]);
561 }
562
563 /// A digest that is always zero is indistinguishable from a crate with no build script.
564 #[test]
565 fn the_crate_identity_is_populated_and_stable() {
566 let once = crate_identity();
567 assert_ne!(once, [0_u8; 32], "the build script did not write a digest");
568 assert_eq!(once, crate_identity());
569 }
570}