Skip to main content

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}