Skip to main content

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}