sui_resolve/lib.rs
1//! Parse-time variable resolution side-table (ENV-RESOLVE M0).
2//!
3//! A single `bind_vars` pass over the rnix AST — walked against a
4//! cppnix-shaped [`StaticEnv`] chain — precomputes, for each variable
5//! *reference* (`ast::Ident` used as an expression), whether that name is
6//! **provably resolvable in an enclosing lexical binder with no `with`
7//! scope between the reference and its binder**. If so, the reference's
8//! interned [`sui_intern::Symbol`] is recorded in a [`ResolveTable`] keyed
9//! by the ident node's byte offset. At eval time, the tree-walker's hot
10//! `Ident` arm consults the table: on a [`Resolution::Lexical`] hit it
11//! probes the environment's lexical bindings with the precomputed Symbol
12//! directly, skipping the per-lookup `ident_text().to_string()` +
13//! `intern()` round-trip.
14//!
15//! # Parity by construction (M0)
16//!
17//! This is a **pure hash-key-caching optimization**. The tree-walker's
18//! `Env::lookup_fast` already probes the lexical `im_rc` bindings map FIRST
19//! (by the same interned Symbol) before ever touching the `with`-chain. So
20//! a `Lexical` fast path that only shortcuts on a *lexical-bindings hit*
21//! returns exactly the value the unchanged path would have — the same
22//! `Env`, the same `Symbol`, the same map probe, just pre-interned. On ANY
23//! miss (a mid-fixpoint blackhole, an ident the table doesn't record, or
24//! `Resolution::Dynamic`) the consumer falls back to today's exact runtime
25//! path (`intern` + `lookup_fast` + the `with`-chain). A byte-identical
26//! parity result is therefore a *sufficient* proof for M0.
27//!
28//! # Fail-safe to Dynamic (the load-bearing discipline)
29//!
30//! `bind_vars` marks a reference [`Resolution::Lexical`] **only** when it
31//! is provably resolvable in an enclosing `let`/`rec`/lambda-param/pattern
32//! binder with **no `with` scope between** the reference and its binder. On
33//! any uncertainty — a reference resolved while a `with`-barrier frame sits
34//! between it and its nearest matching lexical binder, an unresolved name,
35//! an rnix node shape it doesn't model, or a binder whose name it can't
36//! statically read — it emits [`Resolution::Dynamic`] (or simply records
37//! nothing, which the consumer reads back as `Dynamic`). Slower-but-correct
38//! is always right; a mis-marked `Lexical` under a `with` would be the only
39//! thing that could change scoping, and this pass structurally cannot emit
40//! one.
41//!
42//! M1+ (positional `{up, slot}` frames, VM sharing) is explicitly out of
43//! scope here — see `docs/ENV-RESOLVE-DESIGN.md`.
44
45use rnix::ast::{self, AstToken, HasEntry};
46use rowan::ast::AstNode;
47
48use sui_intern::Symbol;
49
50/// The resolution verdict for one variable reference.
51///
52/// M0 only needs to distinguish a precomputed-`Symbol` lexical hit
53/// candidate from "route through the runtime `with`/probe path". The
54/// positional `{up, slot}` variant is M1, deliberately absent here.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum Resolution {
57 /// Provably resolvable in an enclosing lexical binder with no `with`
58 /// scope between the reference and its binder. Carries the reference's
59 /// pre-interned [`Symbol`] so the eval hot path can probe the lexical
60 /// bindings map directly (skipping the re-intern). A lexical-bindings
61 /// *miss* at runtime still falls back to the full unchanged path.
62 Lexical {
63 /// The interned name of the reference.
64 sym: Symbol,
65 },
66 /// Route this reference through today's unchanged runtime path
67 /// (`intern` + `lookup_fast` + the `with`-chain). The fail-safe default
68 /// for any uncertainty.
69 Dynamic,
70}
71
72/// A parse-time resolution side-table for one parsed source tree.
73///
74/// Keyed by the reference ident node's byte offset (`text_range().start()`)
75/// within *this* parse — mirroring how `sui-eval`'s `intern_cached` keys its
76/// per-`(source_id, offset)` ident cache (`value.rs`). The consumer stashes
77/// one table per parsed source (the `source_id` disambiguation lives on the
78/// consumer side, exactly as it does for the ident cache). A lookup for an
79/// unrecorded offset returns [`Resolution::Dynamic`] — the fail-safe path.
80#[derive(Clone, Debug, Default)]
81pub struct ResolveTable {
82 /// `text_offset` -> resolution. Only `Lexical` entries are stored; an
83 /// absent offset reads back as `Dynamic`, so the map stays small (it
84 /// records only the shortcut-eligible references).
85 by_offset: rustc_hash::FxHashMap<u32, Resolution>,
86}
87
88impl ResolveTable {
89 /// An empty table — every lookup returns [`Resolution::Dynamic`].
90 #[must_use]
91 pub fn new() -> Self {
92 Self {
93 by_offset: rustc_hash::FxHashMap::default(),
94 }
95 }
96
97 /// Resolution recorded for a reference at `text_offset`. An unrecorded
98 /// offset is [`Resolution::Dynamic`] (the fail-safe fallback).
99 #[must_use]
100 pub fn get(&self, text_offset: u32) -> Resolution {
101 self.by_offset
102 .get(&text_offset)
103 .copied()
104 .unwrap_or(Resolution::Dynamic)
105 }
106
107 /// Iterate the recorded `(text_offset, resolution)` entries. Only
108 /// `Lexical` entries are stored, so this yields exactly the
109 /// shortcut-eligible references. Consumers merge these into their own
110 /// per-`(source_id, offset)` table.
111 pub fn entries(&self) -> impl Iterator<Item = (u32, Resolution)> + '_ {
112 self.by_offset.iter().map(|(&off, &res)| (off, res))
113 }
114
115 /// Number of `Lexical` entries recorded (diagnostics / tests).
116 #[must_use]
117 pub fn len(&self) -> usize {
118 self.by_offset.len()
119 }
120
121 /// Whether no `Lexical` entry was recorded (diagnostics / tests).
122 #[must_use]
123 pub fn is_empty(&self) -> bool {
124 self.by_offset.is_empty()
125 }
126
127 fn record_lexical(&mut self, text_offset: u32, sym: Symbol) {
128 self.by_offset
129 .insert(text_offset, Resolution::Lexical { sym });
130 }
131}
132
133/// One frame in the static scope chain built during `bind_vars`.
134///
135/// A frame is either a *binder* frame (a `let`/`rec`/lambda-param/pattern
136/// scope, carrying the set of statically-known names it introduces) or a
137/// `with`-**barrier** frame. A reference is `Lexical` iff, scanning frames
138/// innermost-first, we reach a binder frame that contains the name before
139/// crossing any `with`-barrier.
140enum Frame {
141 /// A lexical binder scope. Holds the interned Symbols of every name it
142 /// statically introduces. Only names we can read statically (`Ident`
143 /// binders) are recorded; a name we cannot read (a dynamic/`${…}` key)
144 /// is simply *not present*, so a reference to it fails to resolve and
145 /// falls to `Dynamic` — the safe direction.
146 Binder(Vec<Symbol>),
147 /// A `with` scope barrier. Any reference resolved while this sits
148 /// between it and a matching binder is `Dynamic` (the runtime
149 /// `with`-chain owns it).
150 WithBarrier,
151}
152
153/// The static scope chain — innermost frame last.
154#[derive(Default)]
155struct StaticEnv {
156 frames: Vec<Frame>,
157}
158
159impl StaticEnv {
160 fn push_binder(&mut self, names: Vec<Symbol>) {
161 self.frames.push(Frame::Binder(names));
162 }
163
164 fn push_with(&mut self) {
165 self.frames.push(Frame::WithBarrier);
166 }
167
168 fn pop(&mut self) {
169 self.frames.pop();
170 }
171
172 /// Resolve `sym` against the chain, innermost-first.
173 ///
174 /// Returns `true` (a `Lexical` hit) iff a binder frame containing `sym`
175 /// is reached with **no `with`-barrier crossed first**. A `with`-barrier
176 /// encountered before any matching binder makes the reference `Dynamic`:
177 /// the runtime `with`-chain could supply the name, and any lexical
178 /// binder *outside* the barrier would (correctly) still win at runtime —
179 /// but we must not shortcut it, because if the name is NOT lexically
180 /// bound outside, the `with` must handle it. Conservatively: the first
181 /// barrier we cross ends the `Lexical` guarantee.
182 fn resolves_lexically(&self, sym: Symbol) -> bool {
183 for frame in self.frames.iter().rev() {
184 match frame {
185 Frame::Binder(names) => {
186 if names.contains(&sym) {
187 return true;
188 }
189 }
190 Frame::WithBarrier => return false,
191 }
192 }
193 false
194 }
195}
196
197/// Run the parse-time resolver over a parsed source tree.
198///
199/// Walks the AST rooted at `root`, threading a [`StaticEnv`] of lexical
200/// binder frames and `with`-barriers, and records a [`Resolution::Lexical`]
201/// (with its precomputed [`Symbol`]) for every variable reference provably
202/// resolvable in an enclosing binder with no `with` between. Every other
203/// reference is left unrecorded (read back as [`Resolution::Dynamic`]).
204///
205/// Pure and side-effect-free apart from interning names into the
206/// thread-local interner (the same interner the tree-walker uses, so a
207/// recorded Symbol is the exact one `lookup_fast` would probe with).
208#[must_use]
209pub fn resolve(root: &ast::Root) -> ResolveTable {
210 let mut table = ResolveTable::new();
211 let mut env = StaticEnv::default();
212 if let Some(expr) = root.expr() {
213 walk_expr(&expr, &mut env, &mut table);
214 }
215 table
216}
217
218/// Intern an attr's static name, if it has one. `Ident` and a plain
219/// (non-interpolated) `Str` literal have a statically-known name; a
220/// dynamic `${…}` key does not (returns `None`, so the binder is simply
221/// omitted — the safe direction).
222fn attr_static_sym(attr: &ast::Attr) -> Option<Symbol> {
223 match attr {
224 ast::Attr::Ident(ident) => Some(intern_ident(ident)),
225 ast::Attr::Str(s) => {
226 // A string key with any interpolation part is not statically
227 // nameable. `parts()` yields Literal parts and Interpolation
228 // parts; only an all-literal string has a fixed name.
229 let mut text = String::new();
230 for part in s.parts() {
231 match part {
232 ast::InterpolPart::Literal(lit) => text.push_str(lit.syntax().text()),
233 ast::InterpolPart::Interpolation(_) => return None,
234 }
235 }
236 Some(sui_intern::intern(&text))
237 }
238 ast::Attr::Dynamic(_) => None,
239 }
240}
241
242/// Intern an `Ident`'s text using the thread-local interner.
243fn intern_ident(ident: &ast::Ident) -> Symbol {
244 sui_intern::intern(&ident.syntax().text().to_string())
245}
246
247/// Byte offset of an ident reference node — the same key `intern_cached`
248/// uses as the low 32 bits of its `(source_id, offset)` cache key.
249fn ident_offset(ident: &ast::Ident) -> u32 {
250 u32::from(ident.syntax().text_range().start())
251}
252
253/// The statically-known binder names introduced by a `let …`/`rec { … }`
254/// entry list (an [`ast::HasEntry`]). Records single-segment `Ident`/`Str`
255/// heads of attrpath bindings + `inherit` names. A dynamic head or a
256/// dotted-path head we can't read is simply omitted (safe direction).
257fn entry_binder_syms<E: HasEntry>(entries: &E) -> Vec<Symbol> {
258 let mut syms = Vec::new();
259 for entry in entries.entries() {
260 match entry {
261 ast::Entry::AttrpathValue(apv) => {
262 if let Some(attrpath) = apv.attrpath() {
263 // The BINDER name is the HEAD segment of the attrpath
264 // (`a.b = …` binds `a` in the lexical scope). Record only
265 // the head; a dotted path desugars to a nested set, and
266 // the head is what a bare reference could resolve to.
267 if let Some(head) = attrpath.attrs().next() {
268 if let Some(sym) = attr_static_sym(&head) {
269 syms.push(sym);
270 }
271 }
272 }
273 }
274 ast::Entry::Inherit(inherit) => {
275 for attr in inherit.attrs() {
276 if let Some(sym) = attr_static_sym(&attr) {
277 syms.push(sym);
278 }
279 }
280 }
281 }
282 }
283 syms
284}
285
286/// Walk the *value* expressions of a `let`/`rec` entry list under the given
287/// static env (which must already have the binder frame pushed — `let`/`rec`
288/// scopes are recursive, so RHS values see their own binders). Also walks
289/// any `inherit (from) …` source expressions, which are evaluated in the
290/// *outer* scope — but since the binder frame is already pushed and Nix's
291/// `inherit (e) a;` evaluates `e` in the enclosing scope, we walk `from`
292/// with the binder frame present too; this is conservative-safe (it can
293/// only ADD names in scope for `from`, never remove — and a mis-`Lexical`
294/// is still parity-safe per the module docs). For correctness-of-win we
295/// keep it simple and uniform.
296fn walk_entries<E: HasEntry>(entries: &E, env: &mut StaticEnv, table: &mut ResolveTable) {
297 for entry in entries.entries() {
298 match entry {
299 ast::Entry::AttrpathValue(apv) => {
300 // Dynamic `${…}` segments in the attrpath are themselves
301 // expressions to walk.
302 if let Some(attrpath) = apv.attrpath() {
303 walk_attrpath(&attrpath, env, table);
304 }
305 if let Some(value) = apv.value() {
306 walk_expr(&value, env, table);
307 }
308 }
309 ast::Entry::Inherit(inherit) => {
310 if let Some(from) = inherit.from() {
311 if let Some(expr) = from.expr() {
312 walk_expr(&expr, env, table);
313 }
314 }
315 // `inherit a b;` (no `from`) copies enclosing-scope names —
316 // the names themselves are static Attrs, not references we
317 // resolve here; nothing to walk.
318 }
319 }
320 }
321}
322
323/// Walk any dynamic (`${…}`) segments of an attrpath — a static `Ident`/
324/// `Str` segment is a name, not a reference.
325fn walk_attrpath(attrpath: &ast::Attrpath, env: &mut StaticEnv, table: &mut ResolveTable) {
326 for attr in attrpath.attrs() {
327 if let ast::Attr::Dynamic(dynamic) = attr {
328 if let Some(expr) = dynamic.expr() {
329 walk_expr(&expr, env, table);
330 }
331 }
332 }
333}
334
335/// The core recursive walk. Threads the static-env chain and records
336/// `Lexical` resolutions for bare variable references.
337fn walk_expr(expr: &ast::Expr, env: &mut StaticEnv, table: &mut ResolveTable) {
338 match expr {
339 // ── The reference site ───────────────────────────────────────────
340 ast::Expr::Ident(ident) => {
341 let name = ident.syntax().text().to_string();
342 // Nix keywords are never lexical variables. They are handled by
343 // the eval Ident arm BEFORE any lookup, and are never bound —
344 // never mark them, so the keyword fast path stays intact.
345 if matches!(name.as_str(), "true" | "false" | "null") {
346 return;
347 }
348 let sym = sui_intern::intern(&name);
349 if env.resolves_lexically(sym) {
350 table.record_lexical(ident_offset(ident), sym);
351 }
352 // else: leave unrecorded → Dynamic (fail-safe).
353 }
354
355 // ── Binder scopes ────────────────────────────────────────────────
356 ast::Expr::LetIn(letin) => {
357 let names = entry_binder_syms(letin);
358 env.push_binder(names);
359 walk_entries(letin, env, table);
360 if let Some(body) = letin.body() {
361 walk_expr(&body, env, table);
362 }
363 env.pop();
364 }
365 ast::Expr::LegacyLet(legacy) => {
366 // `let { …; body = …; }` — a recursive binder scope whose result
367 // is its own `body` attr. Treated exactly like a rec-attrset
368 // binder scope for name purposes.
369 let names = entry_binder_syms(legacy);
370 env.push_binder(names);
371 walk_entries(legacy, env, table);
372 env.pop();
373 }
374 ast::Expr::AttrSet(set) => {
375 if set.rec_token().is_some() {
376 // `rec { … }` — a recursive binder scope.
377 let names = entry_binder_syms(set);
378 env.push_binder(names);
379 walk_entries(set, env, table);
380 env.pop();
381 } else {
382 // A plain attrset introduces NO lexical binders (its keys are
383 // not in scope for its own values). Walk values in the
384 // current env; walk dynamic keys too.
385 for entry in set.entries() {
386 match entry {
387 ast::Entry::AttrpathValue(apv) => {
388 if let Some(attrpath) = apv.attrpath() {
389 walk_attrpath(&attrpath, env, table);
390 }
391 if let Some(value) = apv.value() {
392 walk_expr(&value, env, table);
393 }
394 }
395 ast::Entry::Inherit(inherit) => {
396 if let Some(from) = inherit.from() {
397 if let Some(inner) = from.expr() {
398 walk_expr(&inner, env, table);
399 }
400 }
401 }
402 }
403 }
404 }
405 }
406 ast::Expr::Lambda(lambda) => {
407 // A lambda parameter introduces a binder scope for the body.
408 let names = param_binder_syms(lambda.param().as_ref(), env, table);
409 env.push_binder(names);
410 if let Some(body) = lambda.body() {
411 walk_expr(&body, env, table);
412 }
413 env.pop();
414 }
415
416 // ── The `with` barrier ───────────────────────────────────────────
417 ast::Expr::With(with) => {
418 // The NAMESPACE is evaluated in the OUTER scope (no barrier yet).
419 if let Some(ns) = with.namespace() {
420 walk_expr(&ns, env, table);
421 }
422 // The BODY sees a `with`-barrier: any name not lexically bound
423 // OUTSIDE this `with` must route through the runtime with-chain.
424 env.push_with();
425 if let Some(body) = with.body() {
426 walk_expr(&body, env, table);
427 }
428 env.pop();
429 }
430
431 // ── Structural nodes — walk children, no scope change ────────────
432 ast::Expr::Apply(apply) => {
433 if let Some(f) = apply.lambda() {
434 walk_expr(&f, env, table);
435 }
436 if let Some(arg) = apply.argument() {
437 walk_expr(&arg, env, table);
438 }
439 }
440 ast::Expr::Assert(assert) => {
441 if let Some(c) = assert.condition() {
442 walk_expr(&c, env, table);
443 }
444 if let Some(b) = assert.body() {
445 walk_expr(&b, env, table);
446 }
447 }
448 ast::Expr::IfElse(ie) => {
449 if let Some(c) = ie.condition() {
450 walk_expr(&c, env, table);
451 }
452 if let Some(b) = ie.body() {
453 walk_expr(&b, env, table);
454 }
455 if let Some(e) = ie.else_body() {
456 walk_expr(&e, env, table);
457 }
458 }
459 ast::Expr::BinOp(binop) => {
460 if let Some(l) = binop.lhs() {
461 walk_expr(&l, env, table);
462 }
463 if let Some(r) = binop.rhs() {
464 walk_expr(&r, env, table);
465 }
466 }
467 ast::Expr::UnaryOp(unary) => {
468 if let Some(e) = unary.expr() {
469 walk_expr(&e, env, table);
470 }
471 }
472 ast::Expr::Paren(paren) => {
473 if let Some(e) = paren.expr() {
474 walk_expr(&e, env, table);
475 }
476 }
477 ast::Expr::Root(root) => {
478 if let Some(e) = root.expr() {
479 walk_expr(&e, env, table);
480 }
481 }
482 ast::Expr::List(list) => {
483 for item in list.items() {
484 walk_expr(&item, env, table);
485 }
486 }
487 ast::Expr::Select(select) => {
488 // `e.a.b` — walk the BASE expr. The attrpath segments are names
489 // (static) or dynamic `${…}` exprs (walk those).
490 if let Some(base) = select.expr() {
491 walk_expr(&base, env, table);
492 }
493 if let Some(attrpath) = select.attrpath() {
494 walk_attrpath(&attrpath, env, table);
495 }
496 // `or default` fallthrough expr.
497 if let Some(default) = select.default_expr() {
498 walk_expr(&default, env, table);
499 }
500 }
501 ast::Expr::HasAttr(has) => {
502 if let Some(base) = has.expr() {
503 walk_expr(&base, env, table);
504 }
505 if let Some(attrpath) = has.attrpath() {
506 walk_attrpath(&attrpath, env, table);
507 }
508 }
509 ast::Expr::Str(s) => {
510 // Walk interpolation parts (`"${e}"`).
511 for part in s.parts() {
512 if let ast::InterpolPart::Interpolation(interp) = part {
513 if let Some(e) = interp.expr() {
514 walk_expr(&e, env, table);
515 }
516 }
517 }
518 }
519
520 // Path literals may be interpolated (`./${e}`, `~/${e}`, `<${e}>`).
521 ast::Expr::PathAbs(_)
522 | ast::Expr::PathRel(_)
523 | ast::Expr::PathHome(_)
524 | ast::Expr::PathSearch(_) => {
525 walk_path_interpolations(expr, env, table);
526 }
527
528 // Leaves / nodes with no scope-relevant children.
529 ast::Expr::Literal(_) | ast::Expr::Error(_) | ast::Expr::CurPos(_) => {}
530
531 // Any node shape not modeled above: DO NOT descend blindly with a
532 // handwritten child walk (an unmodeled binder would be missed and
533 // could mis-`Lexical`). Fall through to a generic *reference-safe*
534 // descent that walks every child expression WITHOUT introducing or
535 // removing any scope — but because we can't know if an unmodeled
536 // node is a binder, the safe move is to walk children in the CURRENT
537 // env. rnix 0.14's `Expr` enum is closed and fully handled above, so
538 // this arm is unreachable in practice; kept for forward-compat: it
539 // never records a `Lexical` it shouldn't because resolution only
540 // fires on the `Ident` arm against the *current* (unchanged) env.
541 #[allow(unreachable_patterns)]
542 _ => {
543 for child in expr.syntax().children() {
544 if let Some(child_expr) = ast::Expr::cast(child) {
545 walk_expr(&child_expr, env, table);
546 }
547 }
548 }
549 }
550}
551
552/// Walk `${…}` interpolation expressions inside a path literal. rnix
553/// models interpolated path parts as child `Interpol`/`Dynamic`-ish nodes;
554/// we generically descend into any child `Expr` (the static path text
555/// segments are tokens, not `Expr` children, so this only picks up the
556/// interpolated sub-expressions).
557fn walk_path_interpolations(expr: &ast::Expr, env: &mut StaticEnv, table: &mut ResolveTable) {
558 for descendant in expr.syntax().descendants() {
559 // An interpolation's inner expression is the direct `Expr` child of
560 // an `Interpol` node. Find those and walk them.
561 if ast::Interpol::can_cast(descendant.kind()) {
562 if let Some(interp) = ast::Interpol::cast(descendant) {
563 if let Some(inner) = interp.expr() {
564 walk_expr(&inner, env, table);
565 }
566 }
567 }
568 }
569}
570
571/// The binder names a lambda parameter introduces, AND walk any default
572/// expressions in a pattern (which are evaluated with the parameter's own
573/// bindings in scope — Nix pattern defaults are recursive over the pattern).
574///
575/// - `x: body` (an `IdentParam`) binds `x`.
576/// - `{ a, b ? d, ... } @ args: body` (a `Pattern`) binds `a`, `b`, and the
577/// `@`-bound `args`; each `? default` is walked with the pattern's binder
578/// frame in scope (Nix evaluates a formal's default in the function's own
579/// argument scope).
580fn param_binder_syms(
581 param: Option<&ast::Param>,
582 env: &mut StaticEnv,
583 table: &mut ResolveTable,
584) -> Vec<Symbol> {
585 let mut syms = Vec::new();
586 let Some(param) = param else {
587 return syms;
588 };
589 match param {
590 ast::Param::IdentParam(ip) => {
591 if let Some(ident) = ip.ident() {
592 syms.push(intern_ident(&ident));
593 }
594 }
595 ast::Param::Pattern(pattern) => {
596 // Collect every formal name + the `@`-bound name first.
597 for pat_entry in pattern.pat_entries() {
598 if let Some(ident) = pat_entry.ident() {
599 syms.push(intern_ident(&ident));
600 }
601 }
602 if let Some(pat_bind) = pattern.pat_bind() {
603 if let Some(ident) = pat_bind.ident() {
604 syms.push(intern_ident(&ident));
605 }
606 }
607 // Now walk each `? default` with the pattern's binder frame in
608 // scope (defaults are recursive over the pattern in Nix, e.g.
609 // `{ a, b ? a }`). Push the frame, walk defaults, pop — the
610 // caller re-pushes the SAME names for the body.
611 env.push_binder(syms.clone());
612 for pat_entry in pattern.pat_entries() {
613 if let Some(default) = pat_entry.default() {
614 walk_expr(&default, env, table);
615 }
616 }
617 env.pop();
618 }
619 }
620 syms
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 /// Parse + resolve, returning the table.
628 fn resolve_str(src: &str) -> ResolveTable {
629 let parse = rnix::Root::parse(src);
630 assert!(parse.errors().is_empty(), "parse errors: {:?}", parse.errors());
631 resolve(&parse.tree())
632 }
633
634 /// Find the byte offset of the `nth` bare `Ident` *reference* whose text
635 /// equals `name` and that is used as an expression (NOT an attr key /
636 /// binder). A binder ident's parent is an attrpath / inherit / param /
637 /// pattern-entry / pattern-bind node; every other `Ident` node is a
638 /// reference. Offsets are returned in source order.
639 fn ref_offset(src: &str, name: &str, nth: usize) -> u32 {
640 use rnix::SyntaxKind;
641 let parse = rnix::Root::parse(src);
642 let mut hits = Vec::new();
643 for node in parse.syntax().descendants() {
644 if let Some(ast::Expr::Ident(ident)) = ast::Expr::cast(node.clone()) {
645 if ident.syntax().text() != name {
646 continue;
647 }
648 // Exclude binder positions (attr keys, params, pattern names).
649 // In a `NODE_PAT_ENTRY` (`b ? default`) BOTH the formal name
650 // `b` AND the default expr `a` are direct Ident children, so
651 // only the FIRST ident of a PatEntry is the binder; a later
652 // ident there (the default) is a reference.
653 let parent = ident.syntax().parent();
654 let is_binder = parent.as_ref().is_some_and(|p| match p.kind() {
655 SyntaxKind::NODE_ATTRPATH
656 | SyntaxKind::NODE_INHERIT
657 | SyntaxKind::NODE_IDENT_PARAM
658 | SyntaxKind::NODE_PAT_BIND => true,
659 SyntaxKind::NODE_PAT_ENTRY => {
660 // Binder iff it is the entry's first Ident child.
661 p.children()
662 .find(|c| c.kind() == SyntaxKind::NODE_IDENT)
663 .is_some_and(|first| first == *ident.syntax())
664 }
665 _ => false,
666 });
667 if !is_binder {
668 hits.push(u32::from(ident.syntax().text_range().start()));
669 }
670 }
671 }
672 hits.sort_unstable();
673 hits[nth]
674 }
675
676 fn is_lexical(table: &ResolveTable, offset: u32) -> bool {
677 matches!(table.get(offset), Resolution::Lexical { .. })
678 }
679
680 #[test]
681 fn let_body_reference_is_lexical() {
682 let src = "let x = 1; in x";
683 let t = resolve_str(src);
684 let off = ref_offset(src, "x", 0); // the body `x`
685 assert!(is_lexical(&t, off), "body `x` should resolve lexically");
686 }
687
688 #[test]
689 fn recursive_let_sibling_reference_is_lexical() {
690 // `b`'s RHS references `a` — a sibling let binder; lexical.
691 let src = "let a = 1; b = a + 1; in b";
692 let t = resolve_str(src);
693 let off = ref_offset(src, "a", 0); // the reference in `b = a + 1`
694 assert!(is_lexical(&t, off), "sibling `a` should resolve lexically");
695 }
696
697 #[test]
698 fn free_variable_is_dynamic() {
699 let src = "let x = 1; in y";
700 let t = resolve_str(src);
701 let off = ref_offset(src, "y", 0);
702 assert_eq!(t.get(off), Resolution::Dynamic, "free `y` must be Dynamic");
703 }
704
705 #[test]
706 fn reference_under_with_is_dynamic() {
707 // `x` is NOT lexically bound; it can only come from the `with`.
708 let src = "with pkgs; x";
709 let t = resolve_str(src);
710 let off = ref_offset(src, "x", 0);
711 assert_eq!(
712 t.get(off),
713 Resolution::Dynamic,
714 "a name only a `with` could provide must be Dynamic"
715 );
716 }
717
718 #[test]
719 fn lexical_binder_outside_with_is_still_conservatively_dynamic() {
720 // `x` IS lexically bound OUTSIDE the `with`, but a `with`-barrier
721 // sits between the reference and the binder. The fail-safe rule
722 // makes this Dynamic (the runtime path resolves it correctly — the
723 // lexical binding wins there because lookup_fast probes bindings
724 // first — so correctness is preserved; we just don't shortcut it).
725 let src = "let x = 1; in with pkgs; x";
726 let t = resolve_str(src);
727 // Only one `x` REFERENCE exists (the body of the `with`); the other
728 // `x` is the let-binder KEY, which `ref_offset` excludes.
729 let off = ref_offset(src, "x", 0);
730 assert_eq!(
731 t.get(off),
732 Resolution::Dynamic,
733 "a reference under a with-barrier must be Dynamic (fail-safe)"
734 );
735 }
736
737 #[test]
738 fn with_namespace_reference_is_resolved_in_outer_scope() {
739 // `pkgs` in `with pkgs; …` is evaluated in the OUTER scope; if it is
740 // lexically bound there it is Lexical (no barrier over the namespace).
741 let src = "let pkgs = {}; in with pkgs; 1";
742 let t = resolve_str(src);
743 let off = ref_offset(src, "pkgs", 0); // the namespace reference
744 assert!(
745 is_lexical(&t, off),
746 "the with-namespace resolves in the outer (barrier-free) scope"
747 );
748 }
749
750 #[test]
751 fn lambda_param_reference_is_lexical() {
752 let src = "x: x + 1";
753 let t = resolve_str(src);
754 let off = ref_offset(src, "x", 0); // body `x`
755 assert!(is_lexical(&t, off), "lambda param `x` should be lexical");
756 }
757
758 #[test]
759 fn pattern_formal_reference_is_lexical() {
760 let src = "{ a, b }: a + b";
761 let t = resolve_str(src);
762 assert!(is_lexical(&t, ref_offset(src, "a", 0)));
763 assert!(is_lexical(&t, ref_offset(src, "b", 0)));
764 }
765
766 #[test]
767 fn pattern_default_can_reference_sibling_formal() {
768 // `b ? a` — the default references the sibling formal `a`; lexical.
769 let src = "{ a, b ? a }: b";
770 let t = resolve_str(src);
771 let off = ref_offset(src, "a", 0); // the `a` inside `? a`
772 assert!(is_lexical(&t, off), "pattern default sibling ref is lexical");
773 }
774
775 #[test]
776 fn pattern_at_bind_reference_is_lexical() {
777 let src = "{ a } @ args: args";
778 let t = resolve_str(src);
779 let off = ref_offset(src, "args", 0); // body `args`
780 assert!(is_lexical(&t, off), "@-bound name is lexical");
781 }
782
783 #[test]
784 fn rec_attrset_sibling_reference_is_lexical() {
785 let src = "rec { a = 1; b = a; }";
786 let t = resolve_str(src);
787 let off = ref_offset(src, "a", 0); // reference in `b = a`
788 assert!(is_lexical(&t, off), "rec-attrset sibling ref is lexical");
789 }
790
791 #[test]
792 fn plain_attrset_value_reference_to_key_is_dynamic() {
793 // A plain (non-rec) attrset does NOT bind its keys for its values.
794 // `b`'s value references `a`, which is NOT in scope (it's a key of a
795 // non-rec set) → Dynamic.
796 let src = "{ a = 1; b = a; }";
797 let t = resolve_str(src);
798 let off = ref_offset(src, "a", 0);
799 assert_eq!(
800 t.get(off),
801 Resolution::Dynamic,
802 "non-rec attrset keys are NOT lexical for their own values"
803 );
804 }
805
806 #[test]
807 fn nested_let_inner_reference_is_lexical() {
808 let src = "let a = 1; in let b = a; in b";
809 let t = resolve_str(src);
810 // `a` referenced in the inner let — resolves to the outer binder.
811 let off = ref_offset(src, "a", 0);
812 assert!(is_lexical(&t, off));
813 }
814
815 #[test]
816 fn keyword_idents_are_never_recorded() {
817 let src = "let x = true; in if x then true else false";
818 let t = resolve_str(src);
819 // No `true`/`false`/`null` offset should ever be recorded.
820 for node in rnix::Root::parse(src).syntax().descendants() {
821 if let Some(ast::Expr::Ident(ident)) = ast::Expr::cast(node) {
822 let text = ident.syntax().text().to_string();
823 if matches!(text.as_str(), "true" | "false" | "null") {
824 let off = u32::from(ident.syntax().text_range().start());
825 assert_eq!(
826 t.get(off),
827 Resolution::Dynamic,
828 "keyword `{text}` must never be recorded Lexical"
829 );
830 }
831 }
832 }
833 }
834
835 #[test]
836 fn recorded_symbol_matches_interned_name() {
837 let src = "let foo = 1; in foo";
838 let t = resolve_str(src);
839 let off = ref_offset(src, "foo", 0);
840 match t.get(off) {
841 Resolution::Lexical { sym } => {
842 assert_eq!(sym, sui_intern::intern("foo"), "recorded sym is intern(name)");
843 }
844 Resolution::Dynamic => panic!("expected Lexical"),
845 }
846 }
847
848 #[test]
849 fn empty_table_lookup_is_dynamic() {
850 let t = ResolveTable::new();
851 assert_eq!(t.get(0), Resolution::Dynamic);
852 assert_eq!(t.get(9999), Resolution::Dynamic);
853 assert!(t.is_empty());
854 }
855}