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}