Skip to main content

lanekeep_lang/
binding.rs

1//! What an identifier refers to.
2//!
3//! This is the "light binding resolution" of architecture §1 — deliberately not type-aware
4//! analysis. It answers one question: given an identifier, where does the name come from?
5//!
6//! That question is what pure syntactic matching gets wrong. A rule looking for
7//! `makeStyles(...)` by matching the identifier text is wrong twice over: it misses
8//! `import { makeStyles as ms }` and it fires on a local `const makeStyles = ...` that has
9//! nothing to do with the import. Both are ordinary things to write, and both produce
10//! results a user reads as the tool being broken.
11
12use tree_sitter::{Node, Tree};
13
14/// Which export of a module a name came from.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ImportedName {
17    /// `import d from 'm'`
18    Default,
19    /// `import * as ns from 'm'`
20    Namespace,
21    /// `import { a } from 'm'`, or `import { a as b }` where this is `a`.
22    Named(String),
23}
24
25/// How a local name was introduced.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum BindingKind {
28    /// `const x = ...`
29    Const,
30    /// `let x = ...`
31    Let,
32    /// `var x = ...`
33    Var,
34    /// A function or method parameter.
35    Param,
36    /// `function x() {}`
37    Function,
38    /// `class X {}`
39    Class,
40    /// `catch (e)`, and Python's `except E as e`.
41    CatchParam,
42
43    // --- forms with no JavaScript equivalent -------------------------------------------
44    //
45    // Python binds by assigning, and has no keyword to distinguish `const` from `let`. The
46    // kinds below say *how* a name came to be bound, which is the question a rule can act
47    // on — reusing `var` for all of them would answer it with something untrue.
48    /// `x = 1`, an augmented assignment, or a walrus `x := 1`.
49    Assignment,
50    /// The target of a `for` statement or a `for` clause.
51    Loop,
52    /// `with open(p) as f`.
53    ContextManager,
54    /// A comprehension's own target, which is scoped to the comprehension.
55    Comprehension,
56
57    // --- Go ------------------------------------------------------------------------------
58    //
59    // Same reasoning as above, one language further out. Go names things JavaScript has no
60    // word for, and the nearest existing kind would be a lie in each case: a struct is not a
61    // `class`, a method receiver is not quite a `param`, and a type parameter is neither.
62    /// `type T struct{}`, `type T interface{}`, or a `type T = U` alias.
63    Type,
64    /// The receiver of a method — the `r` in `func (r *Repo) Get()`.
65    Receiver,
66    /// A generic type parameter — the `T` in `func F[T any]()` or `type S[T any] struct{}`.
67    TypeParam,
68
69    // --- Rust ----------------------------------------------------------------------------
70    //
71    // Two more, for the same reason as every kind above: the nearest existing one would be a
72    // lie. A module is not a type — it names a namespace, not something a value can be. And
73    // a trait is not a struct; reusing `type` for both would stop a rule asking the one
74    // question it most wants to ask about a trait, which is whether it is one.
75    /// `mod parser;` or `mod parser { ... }`.
76    Module,
77    /// `trait Store { ... }`.
78    Trait,
79}
80
81impl BindingKind {
82    /// The keyword or role, as it appears in a rule's `bindingKind` check.
83    #[must_use]
84    pub const fn as_str(self) -> &'static str {
85        match self {
86            Self::Const => "const",
87            Self::Let => "let",
88            Self::Var => "var",
89            Self::Param => "param",
90            Self::Function => "function",
91            Self::Class => "class",
92            Self::CatchParam => "catch-param",
93            Self::Assignment => "assignment",
94            Self::Loop => "loop",
95            Self::ContextManager => "context-manager",
96            Self::Comprehension => "comprehension",
97            Self::Type => "type",
98            Self::Receiver => "receiver",
99            Self::TypeParam => "type-param",
100            Self::Module => "module",
101            Self::Trait => "trait",
102        }
103    }
104}
105
106/// What an identifier resolves to.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum Binding {
109    /// The name came from an import.
110    Import {
111        /// The module specifier, exactly as written.
112        module: String,
113        /// Which export.
114        name: ImportedName,
115    },
116    /// The name was declared in this file.
117    Local(BindingKind),
118}
119
120impl Binding {
121    /// The kind as a rule sees it, with imports reported as `import`.
122    #[must_use]
123    pub const fn kind_str(&self) -> &'static str {
124        match self {
125            Self::Import { .. } => "import",
126            Self::Local(kind) => kind.as_str(),
127        }
128    }
129
130    /// Whether this is the import a rule asked about — `module` exactly, and the export
131    /// `name` names when it names one.
132    ///
133    /// `name` is spelled as a rule writes it: a named export by its own name, `default` for
134    /// a default import, `*` for a namespace import. `None` matches any export of the
135    /// module, which is the question "did this come from there at all".
136    ///
137    /// Here rather than in an engine because both engines ask it. `lanekeep-js` installs it
138    /// as `ctx.resolvesToImport` and `lanekeep-wasm` implements
139    /// `check-context.resolves-to-import` with it, and a copy in each would let one file
140    /// resolve differently depending on which engine ran the rule — the drift sharing
141    /// `NodeArena` between them exists to prevent, one layer up.
142    #[must_use]
143    pub fn is_import_of(&self, module: &str, name: Option<&str>) -> bool {
144        let Self::Import {
145            module: from,
146            name: imported,
147        } = self
148        else {
149            return false;
150        };
151
152        from.as_str() == module
153            && name.is_none_or(|wanted| match imported {
154                ImportedName::Named(actual) => actual.as_str() == wanted,
155                ImportedName::Default => wanted == "default",
156                ImportedName::Namespace => wanted == "*",
157            })
158    }
159
160    /// Whether this is an import from a module matching `pattern`, where `*` stands for any
161    /// run of characters.
162    ///
163    /// Shared for the same reason [`Binding::is_import_of`] is: `ctx.isImportedFrom` and
164    /// `check-context.is-imported-from` are the same question, so they are one answer.
165    #[must_use]
166    pub fn is_imported_from(&self, pattern: &str) -> bool {
167        match self {
168            Self::Import { module, .. } => glob_matches(pattern, module),
169            Self::Local(_) => false,
170        }
171    }
172}
173
174/// Match a module specifier against a pattern where `*` stands for any run of characters.
175///
176/// Written out rather than pulled in, because the whole need is `@scope/*` and `*/themed`.
177/// A glob crate would bring a dependency and a dialect — character classes, `**`, escapes —
178/// for a surface this small.
179fn glob_matches(pattern: &str, text: &str) -> bool {
180    let mut parts = pattern.split('*');
181    let Some(first) = parts.next() else {
182        return true;
183    };
184    if !text.starts_with(first) {
185        return false;
186    }
187
188    let mut rest = &text[first.len()..];
189    let segments: Vec<&str> = parts.collect();
190
191    // No `*` at all: the pattern has to account for the whole specifier.
192    if segments.is_empty() {
193        return rest.is_empty();
194    }
195
196    for (index, segment) in segments.iter().enumerate() {
197        if segment.is_empty() {
198            continue;
199        }
200        // The final segment has to sit at the end, or `@scope/*` would match
201        // `@scope/pkg/nested` on a pattern the author meant to be exact after the star.
202        if index == segments.len() - 1 {
203            return rest.ends_with(segment);
204        }
205        match rest.find(segment) {
206            Some(at) => rest = &rest[at + segment.len()..],
207            None => return false,
208        }
209    }
210
211    // The pattern ended with `*`, so whatever is left is matched.
212    true
213}
214
215/// Language-specific resolution of identifiers to bindings.
216///
217/// Implementations are expected to be cheap to call repeatedly for one file — the engine
218/// resolves per match, and a query can match many times. Building a per-file index once
219/// and reusing it is the intended shape.
220pub trait BindingResolver: Send + Sync {
221    /// What the identifier at `node` refers to, or `None` if it is not an identifier or
222    /// nothing in this file declares it.
223    fn resolve(&self, tree: &Tree, source: &str, node: Node<'_>) -> Option<Binding>;
224
225    /// Whether the identifier resolves to a binding that shadows an outer one of the same
226    /// name.
227    ///
228    /// Distinct from `resolve` returning a local binding: a name declared once, locally, is
229    /// not shadowing anything. This is specifically "there is more than one, and you got
230    /// the inner one".
231    fn is_shadowed(&self, tree: &Tree, source: &str, node: Node<'_>) -> bool;
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn binding_kinds_render_as_rules_write_them() {
240        assert_eq!(BindingKind::Const.as_str(), "const");
241        assert_eq!(BindingKind::Param.as_str(), "param");
242        assert_eq!(BindingKind::CatchParam.as_str(), "catch-param");
243    }
244
245    #[test]
246    fn imports_report_as_import_regardless_of_which_export() {
247        for name in [
248            ImportedName::Default,
249            ImportedName::Namespace,
250            ImportedName::Named("a".to_owned()),
251        ] {
252            let binding = Binding::Import {
253                module: "m".to_owned(),
254                name,
255            };
256            assert_eq!(binding.kind_str(), "import");
257        }
258    }
259
260    #[test]
261    fn local_bindings_report_their_own_kind() {
262        assert_eq!(Binding::Local(BindingKind::Const).kind_str(), "const");
263        assert_eq!(Binding::Local(BindingKind::Function).kind_str(), "function");
264    }
265
266    /// The import a rule most often asks about: one named export of one module.
267    fn named_import(module: &str, name: &str) -> Binding {
268        Binding::Import {
269            module: module.to_owned(),
270            name: ImportedName::Named(name.to_owned()),
271        }
272    }
273
274    #[test]
275    fn an_import_matches_its_own_module_and_export() {
276        let binding = named_import("@rneui/themed", "makeStyles");
277
278        assert!(binding.is_import_of("@rneui/themed", Some("makeStyles")));
279        assert!(!binding.is_import_of("somewhere-else", Some("makeStyles")));
280        assert!(!binding.is_import_of("@rneui/themed", Some("notThatOne")));
281    }
282
283    #[test]
284    fn omitting_the_name_matches_any_export_of_the_module() {
285        let binding = named_import("m", "a");
286
287        assert!(binding.is_import_of("m", None));
288        assert!(!binding.is_import_of("other", None));
289    }
290
291    #[test]
292    fn the_default_and_namespace_forms_are_named_as_a_rule_writes_them() {
293        // `default` and `*` are the spellings `packages/lanekeep/index.d.ts` documents, and
294        // they are what a rule has to write — there is no other way to name those imports.
295        let default = Binding::Import {
296            module: "m".to_owned(),
297            name: ImportedName::Default,
298        };
299        assert!(default.is_import_of("m", Some("default")));
300        assert!(!default.is_import_of("m", Some("*")));
301        assert!(!default.is_import_of("m", Some("a")));
302
303        let namespace = Binding::Import {
304            module: "m".to_owned(),
305            name: ImportedName::Namespace,
306        };
307        assert!(namespace.is_import_of("m", Some("*")));
308        assert!(!namespace.is_import_of("m", Some("default")));
309
310        // A named export called `default` is `import { default as d }`, which is the same
311        // export the default form names — so both answering `true` is correct rather than a
312        // collision.
313        assert!(named_import("m", "default").is_import_of("m", Some("default")));
314    }
315
316    #[test]
317    fn a_local_binding_is_no_import_at_all() {
318        let local = Binding::Local(BindingKind::Const);
319
320        assert!(!local.is_import_of("m", None));
321        assert!(!local.is_imported_from("*"));
322    }
323
324    #[test]
325    fn glob_matching_handles_the_shapes_that_appear_in_rules() {
326        assert!(glob_matches("m", "m"));
327        assert!(!glob_matches("m", "mm"));
328        assert!(glob_matches("*", "anything"));
329        assert!(glob_matches("@scope/*", "@scope/pkg"));
330        assert!(!glob_matches("@scope/*", "@other/pkg"));
331        assert!(glob_matches("*/themed", "@rneui/themed"));
332        assert!(!glob_matches("*/themed", "@rneui/other"));
333        assert!(glob_matches("@a/*/c", "@a/b/c"));
334        assert!(!glob_matches("@a/*/c", "@a/b/d"));
335        assert!(glob_matches("", ""));
336        assert!(!glob_matches("", "x"));
337    }
338
339    #[test]
340    fn an_import_is_matched_by_a_glob_over_its_module() {
341        let binding = named_import("@scope/pkg", "a");
342
343        assert!(binding.is_imported_from("@scope/*"));
344        assert!(binding.is_imported_from("*/pkg"));
345        assert!(binding.is_imported_from("@scope/pkg"));
346        assert!(!binding.is_imported_from("@other/*"));
347    }
348}