Skip to main content

blue_lang_syntax/
scope.rs

1//! The binder grammar: which forms open a scope, which names they bind, and
2//! which symbols are references. **One walker, and every pass that needs
3//! scopes drives it** — the check stage's name table
4//! (`blue_lang_check::names`) and the capability frame's reach walk
5//! (`blue_lang_waku::check_reach_program`). Two hand-written walkers used to
6//! answer this question, and they disagreed: waku bound a `define` only at the
7//! level of a `begin`, so a name assigned inside an `if` branch and read after
8//! it was reported as an escape, and it knew no `let*`, `letrec` or `catch`.
9//!
10//! The walker knows the SHAPES of tatara-lisp's binding forms, as blue lowers
11//! them: `lambda` and a function `define` introduce parameters; `let`, `let*`
12//! and `letrec` their bindings (a named `let` its name); `try`'s `catch` clause
13//! its variable; a `define` inside a body binds in that body's frame,
14//! including through `if`, `cond` and `begin`, which open no frame of their
15//! own. `quote` is data; inside a quasiquote only the `unquote`d parts are
16//! code, and the rest is handed to [`Scopes::template_symbol`].
17//!
18//! What a pass DOES with a binding or a reference is the [`Scopes`]
19//! implementation's business — the checker tracks reads for its unused
20//! warning, the reach walk tests each free name against a frame — and which
21//! head is a special form or a macro is the implementation's oracle
22//! ([`Scopes::head_kind`]), since only the pass knows which interpreter it
23//! answers to.
24
25use tatara_lisp::{Atom, Span, Spanned, SpannedForm};
26
27/// Which of the evaluator's three arbiters claims a name in head position.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum HeadKind {
30    SpecialForm,
31    Macro,
32    Value,
33}
34
35/// What introduced a local binding.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum BinderKind {
38    Parameter,
39    /// `x = e` inside a body.
40    Assignment,
41    /// `def f(…)` inside a body, or a named `let`.
42    Function,
43    Let,
44    Catch,
45}
46
47/// One `use` form, as the file that wrote it declared it.
48///
49/// `use("moji")` makes `moji::split` reachable; `use("retsu", [:first,
50/// :size])` also makes `first` and `size` reachable bare. The form stays in
51/// the program (after the package's spliced forms) so the check stage can
52/// point at it, and evaluates to nothing (`blue_lang_runtime::uses::ResolvedProgram::sexps`).
53#[derive(Clone, Debug, PartialEq)]
54pub struct Import {
55    pub package: String,
56    /// The names listed for bare use, each with its `:name` span. Empty for
57    /// `use("moji")`.
58    pub names: Vec<(String, Span)>,
59    /// Whether a list was written at all: `use("x", [])` lists nothing.
60    pub listed: bool,
61    /// The whole `use(...)` form.
62    pub span: Span,
63    /// The span of the package-name string.
64    pub package_span: Span,
65}
66
67/// Is this form a `use` declaration? If so, what it declares.
68///
69/// `use("name")` or `use("name", [:a, :b])`, the call form only. `use "kazu"`
70/// without parentheses parses as two unrelated top-level atoms (blue has no
71/// paren-less call syntax), which would silently do nothing — so it is not
72/// treated as an import, and the bare symbol `use` then fails as an unbound
73/// name rather than being quietly ignored. A list that holds anything but
74/// `:name` symbols is not a declaration either, for the same reason.
75#[must_use]
76pub fn use_target(form: &Spanned) -> Option<Import> {
77    let items = form.as_list()?;
78    let (head, arg, list) = match items {
79        [head, arg] => (head, arg, None),
80        [head, arg, list] => (head, arg, Some(list)),
81        _ => return None,
82    };
83    let (SpannedForm::Atom(Atom::Symbol(s)), SpannedForm::Atom(Atom::Str(name))) =
84        (&head.form, &arg.form)
85    else {
86        return None;
87    };
88    if s != "use" {
89        return None;
90    }
91    let mut names = Vec::new();
92    if let Some(list) = list {
93        let parts = list.as_list()?;
94        if parts.first().and_then(Spanned::as_symbol) != Some("list") {
95            return None;
96        }
97        for p in &parts[1..] {
98            match &p.form {
99                SpannedForm::Atom(Atom::Keyword(k)) => names.push((k.clone(), p.span)),
100                _ => return None,
101            }
102        }
103    }
104    Some(Import {
105        package: name.clone(),
106        names,
107        listed: list.is_some(),
108        span: form.span,
109        package_span: arg.span,
110    })
111}
112
113/// The head of the rename-ledger declaration: `legacy_names("0.1.1", "lc")`.
114pub const LEGACY_NAMES: &str = "legacy_names";
115
116/// A bidama's rename ledger, declared at its top level. Two forms:
117///
118/// - `legacy_names(since, prefix)`: the bidama's definitions were once all
119///   named `prefix_x`. From version `since`, every definition `x` is also
120///   reachable as `prefix_x` — a BRIDGE for callers still on the old
121///   spelling, open while the bidama's version is at least `since` and below
122///   the next minor — and a definition that must keep its prefix (the
123///   stripped name is a reserved word or a builtin the bidama uses bare) is
124///   also reachable by its stripped name, for good.
125/// - `legacy_names(since, [["old", "home::new"], …])`: definitions the
126///   bidama MOVED to another bidama. `old` stays a bridge to `home::new`
127///   over the same window, so a twin is moved, not copied.
128///
129/// Like `use`, it is consumed by the resolver and evaluates to nothing; an
130/// expired ledger stays in the source, and powers the fix for a late caller.
131#[derive(Clone, Debug, PartialEq)]
132pub struct Legacy {
133    pub since: String,
134    pub kind: LegacyKind,
135    pub span: Span,
136}
137
138/// What a `legacy_names` declaration records.
139#[derive(Clone, Debug, PartialEq)]
140pub enum LegacyKind {
141    /// The prefix every definition once carried.
142    Prefix(String),
143    /// Definitions moved out of the bidama, in source order.
144    Moved(Vec<Moved>),
145}
146
147/// One moved definition: `old` in the declaring bidama is `home::new` now.
148/// `home` is empty when the target was not written `home::new`.
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct Moved {
151    pub old: String,
152    pub home: String,
153    pub new: String,
154}
155
156/// Is this form a `legacy_names` declaration? A version string, then a
157/// prefix string or a list of `[old, "home::new"]` string pairs, or it is not
158/// one (and `legacy_names` is then an unbound call).
159#[must_use]
160pub fn legacy_target(form: &Spanned) -> Option<Legacy> {
161    let items = form.as_list()?;
162    let [head, since, what] = items else {
163        return None;
164    };
165    if head.as_symbol() != Some(LEGACY_NAMES) {
166        return None;
167    }
168    let SpannedForm::Atom(Atom::Str(since)) = &since.form else {
169        return None;
170    };
171    let str_of = |s: &Spanned| match &s.form {
172        SpannedForm::Atom(Atom::Str(x)) => Some(x.clone()),
173        _ => None,
174    };
175    let kind = if let Some(prefix) = str_of(what) {
176        LegacyKind::Prefix(prefix)
177    } else {
178        let moves = list_items(what)?
179            .iter()
180            .map(|pair| {
181                let [old, target] = list_items(pair)? else {
182                    return None;
183                };
184                let (old, target) = (str_of(old)?, str_of(target)?);
185                let (home, new) = target
186                    .split_once("::")
187                    .map_or((String::new(), target.clone()), |(h, n)| {
188                        (h.to_string(), n.to_string())
189                    });
190                Some(Moved { old, home, new })
191            })
192            .collect::<Option<Vec<_>>>()?;
193        LegacyKind::Moved(moves)
194    };
195    Some(Legacy {
196        since: since.clone(),
197        kind,
198        span: form.span,
199    })
200}
201
202/// The elements of a list literal `[a, b]`, which parses to `(list a b)`.
203fn list_items(form: &Spanned) -> Option<&[Spanned]> {
204    let items = form.as_list()?;
205    (items.first()?.as_symbol() == Some("list")).then(|| &items[1..])
206}
207
208/// The special forms whose SHAPE the walker knows — the heads a pass without
209/// an interpreter to ask (the reach walk) answers [`HeadKind::SpecialForm`]
210/// for, so their binders bind. Every other special form is walked as an
211/// ordinary application, which is what it is for scoping purposes.
212pub const SHAPED_FORMS: &[&str] = &[
213    "lambda",
214    "let",
215    "let*",
216    "letrec",
217    "try",
218    "cond",
219    "quote",
220    "quasiquote",
221    "provide",
222    "require",
223];
224
225/// A pass over the binder grammar. The walker calls these in source order.
226pub trait Scopes {
227    /// Which arbiter claims `name` as a head, if any. `None` walks the call
228    /// as an ordinary application.
229    fn head_kind(&self, name: &str) -> Option<HeadKind>;
230    /// A frame opens: a function body, a `let`, a `catch`, a test body.
231    fn open(&mut self);
232    /// `name` is bound in the innermost open frame.
233    fn bind(&mut self, name: &str, span: Span, kind: BinderKind);
234    /// The innermost frame closes.
235    fn close(&mut self);
236    /// A symbol in code position. `opaque`: inside a macro call's arguments,
237    /// which are syntax the macro may never evaluate.
238    fn reference(&mut self, node: &Spanned, name: &str, opaque: bool);
239    fn call(&mut self, node: &Spanned, _whole: &Spanned, name: &str, opaque: bool) {
240        self.reference(node, name, opaque);
241    }
242    fn place(&mut self, node: &Spanned, name: &str, opaque: bool) {
243        self.reference(node, name, opaque);
244    }
245    /// The head of a special form or a macro call — not a reference to a
246    /// binding, but still a name the program writes.
247    fn head(&mut self, _node: &Spanned, _name: &str, _kind: HeadKind) {}
248    /// A symbol inside a quasiquoted template, outside any `unquote`: data at
249    /// expansion, code once the expansion runs.
250    fn template_symbol(&mut self, _node: &Spanned, _name: &str) {}
251}
252
253/// Walk a whole program. Top-level definitions are the pass's to know (they
254/// are globals, see [`definitions_of`]); the walker opens frames only below
255/// them.
256pub fn walk_program(forms: &[Spanned], pass: &mut dyn Scopes) {
257    for form in forms {
258        walk_top(form, pass);
259    }
260}
261
262/// Walk one top-level form.
263pub fn walk_top(form: &Spanned, pass: &mut dyn Scopes) {
264    Walker { pass, depth: 0 }.walk(form, false);
265}
266
267/// The names `form` defines into the frame it is evaluated in, with the
268/// span of each name and whether it is a value or a macro.
269///
270/// A `define` binds in the environment it is EVALUATED in, and every part of
271/// a form is evaluated in the enclosing environment except the parts of a
272/// frame-opening form (`lambda`, a function `define`, `let`, a `catch`
273/// clause, a test body) and quoted data. So this descends into every
274/// sub-form but those: `x = if c … else y = 1 … end` binds `y` in the
275/// function's frame, which is where a later `fn(s) … y … end` reads it.
276#[must_use]
277pub fn definitions_of(form: &Spanned) -> Vec<(String, Span, HeadKind)> {
278    let mut out = Vec::new();
279    definition_nodes(form, &mut |node, kind| {
280        if let Some(n) = node.as_symbol() {
281            out.push((n.to_string(), node.span, kind));
282        }
283    });
284    out
285}
286
287/// [`definitions_of`], handing over the NAME NODE of each definition, so a
288/// pass that rewrites definitions can find the symbol it renames.
289pub fn definition_nodes<'a>(form: &'a Spanned, out: &mut dyn FnMut(&'a Spanned, HeadKind)) {
290    let Some(items) = form.as_list() else { return };
291    let head = items.first().and_then(Spanned::as_symbol);
292    match head {
293        Some("defmacro") => {
294            if let Some(n) = items.get(1).filter(|n| n.as_symbol().is_some()) {
295                out(n, HeadKind::Macro);
296            }
297        }
298        Some("define" | "define-typed") => {
299            if let Some(n) = defined_name_node(items) {
300                out(n, HeadKind::Value);
301            }
302            // `(define x e)`: `e` is evaluated right here, so its defines are
303            // this frame's too. A function define opens a frame; stop.
304            if items.get(1).is_some_and(|t| t.as_symbol().is_some()) {
305                for item in &items[2..] {
306                    definition_nodes(item, out);
307                }
308            }
309        }
310        Some("lambda" | "let" | "let*" | "letrec" | "deftest" | "quote" | "quasiquote") => {}
311        Some("try") => {
312            // The body shares the frame; a `catch` clause opens its own.
313            for item in &items[1..] {
314                let is_clause = item
315                    .as_list()
316                    .and_then(|c| c.first())
317                    .and_then(Spanned::as_symbol)
318                    .is_some_and(|h| h == "catch" || h == "finally");
319                if !is_clause {
320                    definition_nodes(item, out);
321                }
322            }
323        }
324        _ => {
325            for item in items {
326                definition_nodes(item, out);
327            }
328        }
329    }
330}
331
332#[must_use]
333pub fn nullary_definition(form: &Spanned) -> Option<&str> {
334    let items = form.as_list()?;
335    let head = items.first()?.as_symbol()?;
336    if head != "define" && head != "define-typed" {
337        return None;
338    }
339    match items.get(1)?.as_list()? {
340        [name] => name.as_symbol(),
341        _ => None,
342    }
343}
344
345/// The name node a `define`/`define-typed` form binds.
346#[must_use]
347pub fn defined_name_node(items: &[Spanned]) -> Option<&Spanned> {
348    let head = items.first()?.as_symbol()?;
349    if head != "define" && head != "define-typed" {
350        return None;
351    }
352    let target = items.get(1)?;
353    if target.as_symbol().is_some() {
354        return Some(target);
355    }
356    let name = target.as_list()?.first()?;
357    name.as_symbol().map(|_| name)
358}
359
360/// A lambda parameter list: symbols, skipping `&rest`/`&optional`/`.`
361/// markers; a bare symbol is a variadic parameter.
362#[must_use]
363pub fn params_of(list: &Spanned) -> Vec<(String, Span)> {
364    if let Some(n) = list.as_symbol() {
365        return vec![(n.to_string(), list.span)];
366    }
367    list.as_list()
368        .map(|ps| ps.iter().filter_map(param).collect())
369        .unwrap_or_default()
370}
371
372fn param(p: &Spanned) -> Option<(String, Span)> {
373    let n = p.as_symbol()?;
374    if n.starts_with('&') || n == "." {
375        return None;
376    }
377    Some((n.to_string(), p.span))
378}
379
380struct Walker<'p> {
381    pass: &'p mut dyn Scopes,
382    /// Open frames. Zero at the top level of a form.
383    depth: usize,
384}
385
386impl Walker<'_> {
387    fn open(&mut self) {
388        self.depth += 1;
389        self.pass.open();
390    }
391
392    fn close(&mut self) {
393        self.depth -= 1;
394        self.pass.close();
395    }
396
397    /// Bind, in the current frame, every `define` a body reaches without
398    /// crossing a frame boundary — so a body can read a local defined later in
399    /// it, as a closure over the frame can at runtime.
400    fn hoist(&mut self, body: &[Spanned]) {
401        for f in body {
402            let function = f.as_list().is_some_and(|items| {
403                items.first().and_then(Spanned::as_symbol) == Some("define")
404                    && items.get(1).is_some_and(|t| t.as_list().is_some())
405            });
406            let pass = &mut *self.pass;
407            definition_nodes(f, &mut |node, kind| {
408                let Some(name) = node.as_symbol() else { return };
409                let bk = match kind {
410                    HeadKind::Value if !function => BinderKind::Assignment,
411                    _ => BinderKind::Function,
412                };
413                pass.bind(name, node.span, bk);
414            });
415        }
416    }
417
418    /// Walk a body: a new frame, its parameters bound, its defines hoisted,
419    /// each form walked.
420    fn body(&mut self, params: &[(String, Span)], forms: &[Spanned], opaque: bool) {
421        self.open();
422        for (p, s) in params {
423            self.pass.bind(p, *s, BinderKind::Parameter);
424        }
425        self.hoist(forms);
426        for f in forms {
427            self.walk(f, opaque);
428        }
429        self.close();
430    }
431
432    fn walk(&mut self, form: &Spanned, opaque: bool) {
433        match &form.form {
434            SpannedForm::Atom(Atom::Symbol(n)) => self.pass.reference(form, n, opaque),
435            SpannedForm::Quote(_) => {}
436            SpannedForm::Quasiquote(inner) => self.walk_template(inner, opaque),
437            SpannedForm::Unquote(inner) | SpannedForm::UnquoteSplice(inner) => {
438                self.walk(inner, opaque);
439            }
440            SpannedForm::List(items) if !items.is_empty() => self.walk_list(form, items, opaque),
441            _ => {}
442        }
443    }
444
445    /// Inside a quasiquote only the unquoted parts are code.
446    fn walk_template(&mut self, form: &Spanned, opaque: bool) {
447        match &form.form {
448            SpannedForm::Atom(Atom::Symbol(n)) => self.pass.template_symbol(form, n),
449            SpannedForm::Unquote(inner) | SpannedForm::UnquoteSplice(inner) => {
450                self.walk(inner, opaque);
451            }
452            SpannedForm::List(items) => {
453                for i in items {
454                    self.walk_template(i, opaque);
455                }
456            }
457            SpannedForm::Quasiquote(inner) => self.walk_template(inner, opaque),
458            _ => {}
459        }
460    }
461
462    fn walk_all(&mut self, items: &[Spanned], opaque: bool) {
463        for i in items {
464            self.walk(i, opaque);
465        }
466    }
467
468    fn walk_list(&mut self, form: &Spanned, items: &[Spanned], opaque: bool) {
469        let Some(head) = items[0].as_symbol() else {
470            self.walk_all(items, opaque);
471            return;
472        };
473        // `deftest` is the test harness's declaration, not a special form: a
474        // body, run in a frame of its own.
475        if head == "deftest" && self.depth == 0 {
476            if let Some(body) = items.get(2) {
477                self.body(&[], std::slice::from_ref(body), opaque);
478            }
479            return;
480        }
481        // `use("x", [:a])` at the top level is a declaration the resolver
482        // consumed, not a call: nothing in it is a reference.
483        if (head == "use" || head == LEGACY_NAMES) && self.depth == 0 {
484            self.pass.head(&items[0], head, HeadKind::SpecialForm);
485            return;
486        }
487        // `define-typed` is erased before evaluation; its shape is `define`'s
488        // with each parameter written `(name Type)`.
489        if head == "define" || head == "define-typed" {
490            self.pass.head(&items[0], head, HeadKind::SpecialForm);
491            self.walk_define(head, items, opaque);
492            return;
493        }
494        // `defmacro` is registered by the expander before evaluation, not
495        // dispatched as a special form, so no arbiter claims it as a head.
496        // `(defmacro name (params) body…)`.
497        if head == "defmacro" && self.depth == 0 {
498            self.pass.head(&items[0], head, HeadKind::SpecialForm);
499            let params = items.get(2).map(params_of).unwrap_or_default();
500            self.body(&params, items.get(3..).unwrap_or(&[]), opaque);
501            return;
502        }
503        match self.pass.head_kind(head) {
504            Some(HeadKind::SpecialForm) => {
505                self.pass.head(&items[0], head, HeadKind::SpecialForm);
506                self.walk_special(head, items, opaque);
507            }
508            Some(HeadKind::Macro) => {
509                // Opaque: a macro's arguments are syntax.
510                self.pass.head(&items[0], head, HeadKind::Macro);
511                for i in &items[1..] {
512                    self.walk(i, true);
513                }
514            }
515            Some(HeadKind::Value) | None => {
516                self.pass.call(&items[0], form, head, opaque);
517                self.walk_all(&items[1..], opaque);
518            }
519        }
520    }
521
522    fn walk_define(&mut self, head: &str, items: &[Spanned], opaque: bool) {
523        let Some(target) = items.get(1) else { return };
524        if target.as_symbol().is_some() {
525            // `(define x e)`: `x` is already bound (hoisted, or a global).
526            self.walk_all(&items[2..], opaque);
527            return;
528        }
529        let Some(sig) = target.as_list() else {
530            self.walk_all(&items[1..], opaque);
531            return;
532        };
533        let params: Vec<(String, Span)> = sig
534            .iter()
535            .skip(1)
536            .filter_map(|p| {
537                if head == "define-typed" {
538                    let pair = p.as_list()?;
539                    let n = pair.first()?;
540                    Some((n.as_symbol()?.to_string(), n.span))
541                } else {
542                    param(p)
543                }
544            })
545            .collect();
546        // `(define-typed sig R body)`: the return type is not code.
547        let body_from = if head == "define-typed" { 3 } else { 2 };
548        self.body(&params, items.get(body_from..).unwrap_or(&[]), opaque);
549    }
550
551    fn walk_special(&mut self, head: &str, items: &[Spanned], opaque: bool) {
552        let args = &items[1..];
553        match head {
554            "quote" | "provide" | "require" => {}
555            "quasiquote" => {
556                for a in args {
557                    self.walk_template(a, opaque);
558                }
559            }
560            "lambda" => {
561                let params = args.first().map(params_of).unwrap_or_default();
562                self.body(&params, args.get(1..).unwrap_or(&[]), opaque);
563            }
564            "cond" => {
565                for clause in args {
566                    match clause.as_list() {
567                        Some(parts) => {
568                            let rest = match parts.first().and_then(Spanned::as_symbol) {
569                                Some("else") => &parts[1..],
570                                _ => parts,
571                            };
572                            for p in rest {
573                                if p.as_symbol() != Some("=>") {
574                                    self.walk(p, opaque);
575                                }
576                            }
577                        }
578                        None => self.walk(clause, opaque),
579                    }
580                }
581            }
582            "let" | "let*" | "letrec" => self.walk_let(head, args, opaque),
583            "set!" => match args.split_first() {
584                Some((target, rest)) if target.as_symbol().is_some() => {
585                    self.pass
586                        .place(target, target.as_symbol().expect("matched"), opaque);
587                    self.walk_all(rest, opaque);
588                }
589                _ => self.walk_all(args, opaque),
590            },
591            "try" => {
592                for a in args {
593                    let clause = a.as_list();
594                    match clause.and_then(|c| c.first()).and_then(Spanned::as_symbol) {
595                        Some("catch") => {
596                            let c = clause.expect("matched");
597                            let var = c.get(1).and_then(|v| {
598                                v.as_list()
599                                    .and_then(|l| l.first())
600                                    .and_then(|s| s.as_symbol().map(|n| (n.to_string(), s.span)))
601                                    .or_else(|| v.as_symbol().map(|n| (n.to_string(), v.span)))
602                            });
603                            self.open();
604                            if let Some((n, s)) = var {
605                                self.pass.bind(&n, s, BinderKind::Catch);
606                            }
607                            self.walk_all(c.get(2..).unwrap_or(&[]), opaque);
608                            self.close();
609                        }
610                        Some("finally") => {
611                            let c = clause.expect("matched");
612                            self.walk_all(&c[1..], opaque);
613                        }
614                        _ => self.walk(a, opaque),
615                    }
616                }
617            }
618            // if, when, unless, and, or, not, begin, set!, delay, eval,
619            // macroexpand, macroexpand-1: every part is an expression.
620            _ => self.walk_all(args, opaque),
621        }
622    }
623
624    fn walk_let(&mut self, head: &str, args: &[Spanned], opaque: bool) {
625        // Named let: `(let name ((a e) …) body…)`.
626        let (name, rest) = match args.first() {
627            Some(n) if n.as_symbol().is_some() && head == "let" => (Some(n), &args[1..]),
628            _ => (None, args),
629        };
630        let Some(bindings) = rest.first().and_then(Spanned::as_list) else {
631            self.walk_all(rest, opaque);
632            return;
633        };
634        let pairs: Vec<(String, Span, Option<&Spanned>)> = bindings
635            .iter()
636            .filter_map(|b| match b.as_list() {
637                Some(pair) => {
638                    let n = pair.first()?;
639                    Some((n.as_symbol()?.to_string(), n.span, pair.get(1)))
640                }
641                None => b.as_symbol().map(|n| (n.to_string(), b.span, None)),
642            })
643            .collect();
644        let body = rest.get(1..).unwrap_or(&[]);
645        if head == "let" {
646            // Inits see the OUTER scope: walk them before the frame opens.
647            for (_, _, init) in &pairs {
648                if let Some(e) = init {
649                    self.walk(e, opaque);
650                }
651            }
652        }
653        self.open();
654        match head {
655            "let" => {
656                if let Some(n) = name {
657                    self.pass.bind(
658                        n.as_symbol().expect("matched"),
659                        n.span,
660                        BinderKind::Function,
661                    );
662                }
663                for (n, s, _) in &pairs {
664                    self.pass.bind(n, *s, BinderKind::Let);
665                }
666            }
667            "let*" => {
668                for (n, s, init) in &pairs {
669                    if let Some(e) = init {
670                        self.walk(e, opaque);
671                    }
672                    self.pass.bind(n, *s, BinderKind::Let);
673                }
674            }
675            _ => {
676                for (n, s, _) in &pairs {
677                    self.pass.bind(n, *s, BinderKind::Let);
678                }
679                for (_, _, init) in &pairs {
680                    if let Some(e) = init {
681                        self.walk(e, opaque);
682                    }
683                }
684            }
685        }
686        self.hoist(body);
687        self.walk_all(body, opaque);
688        self.close();
689    }
690}