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/// - `legacy_names(since, [:first, …])`: definitions that share a builtin's
129///   name and were reached bare under the flat runtime. Over the same window
130///   an importer's bare call of one is B0012; outside it, and for every
131///   definition not listed, the importer's bare name is the builtin.
132///
133/// Like `use`, it is consumed by the resolver and evaluates to nothing; an
134/// expired ledger stays in the source, and powers the fix for a late caller.
135#[derive(Clone, Debug, PartialEq)]
136pub struct Legacy {
137    pub since: String,
138    pub kind: LegacyKind,
139    pub span: Span,
140}
141
142/// What a `legacy_names` declaration records.
143#[derive(Clone, Debug, PartialEq)]
144pub enum LegacyKind {
145    /// The prefix every definition once carried.
146    Prefix(String),
147    /// Definitions moved out of the bidama, in source order.
148    Moved(Vec<Moved>),
149    Flat(Vec<(String, Span)>),
150}
151
152/// One moved definition: `old` in the declaring bidama is `home::new` now.
153/// `home` is empty when the target was not written `home::new`.
154///
155/// A third string retires a word whose replacement takes more arguments:
156/// `["refuse", "kyohi::enforce", "[(fs) -> fs], {kind: :first}"]` says
157/// `refuse(x)` is written `kyohi::enforce(x, [(fs) -> fs], {kind: :first})`.
158/// A retired word never resolves; its callers' fix is read from this row.
159#[derive(Clone, Debug, PartialEq, Eq)]
160pub struct Moved {
161    pub old: String,
162    pub home: String,
163    pub new: String,
164    pub append: Option<String>,
165}
166
167/// Is this form a `legacy_names` declaration? A version string, then a
168/// prefix string, a list of `[old, "home::new"]` string pairs (or
169/// `[old, "home::new", "args"]` retirements), or a list of flat-era
170/// `:name` keywords, or it is not
171/// one (and `legacy_names` is then an unbound call).
172#[must_use]
173pub fn legacy_target(form: &Spanned) -> Option<Legacy> {
174    let items = form.as_list()?;
175    let [head, since, what] = items else {
176        return None;
177    };
178    if head.as_symbol() != Some(LEGACY_NAMES) {
179        return None;
180    }
181    let SpannedForm::Atom(Atom::Str(since)) = &since.form else {
182        return None;
183    };
184    let str_of = |s: &Spanned| match &s.form {
185        SpannedForm::Atom(Atom::Str(x)) => Some(x.clone()),
186        _ => None,
187    };
188    let keyword_of = |s: &Spanned| match &s.form {
189        SpannedForm::Atom(Atom::Keyword(k)) => Some((k.clone(), s.span)),
190        _ => None,
191    };
192    let kind = if let Some(prefix) = str_of(what) {
193        LegacyKind::Prefix(prefix)
194    } else if let Some(flat) = list_items(what)
195        .filter(|items| !items.is_empty())
196        .and_then(|items| items.iter().map(keyword_of).collect::<Option<Vec<_>>>())
197    {
198        LegacyKind::Flat(flat)
199    } else {
200        let moves = list_items(what)?
201            .iter()
202            .map(|pair| {
203                let (old, target, append) = match list_items(pair)? {
204                    [old, target] => (old, target, None),
205                    [old, target, append] => (old, target, Some(str_of(append)?)),
206                    _ => return None,
207                };
208                let (old, target) = (str_of(old)?, str_of(target)?);
209                let (home, new) = target
210                    .split_once("::")
211                    .map_or((String::new(), target.clone()), |(h, n)| {
212                        (h.to_string(), n.to_string())
213                    });
214                Some(Moved {
215                    old,
216                    home,
217                    new,
218                    append,
219                })
220            })
221            .collect::<Option<Vec<_>>>()?;
222        LegacyKind::Moved(moves)
223    };
224    Some(Legacy {
225        since: since.clone(),
226        kind,
227        span: form.span,
228    })
229}
230
231/// The elements of a list literal `[a, b]`, which parses to `(list a b)`.
232fn list_items(form: &Spanned) -> Option<&[Spanned]> {
233    let items = form.as_list()?;
234    (items.first()?.as_symbol() == Some("list")).then(|| &items[1..])
235}
236
237/// The special forms whose SHAPE the walker knows — the heads a pass without
238/// an interpreter to ask (the reach walk) answers [`HeadKind::SpecialForm`]
239/// for, so their binders bind. Every other special form is walked as an
240/// ordinary application, which is what it is for scoping purposes.
241pub const SHAPED_FORMS: &[&str] = &[
242    "lambda",
243    "let",
244    "let*",
245    "letrec",
246    "try",
247    "cond",
248    "quote",
249    "quasiquote",
250    "provide",
251    "require",
252];
253
254/// A pass over the binder grammar. The walker calls these in source order.
255pub trait Scopes {
256    /// Which arbiter claims `name` as a head, if any. `None` walks the call
257    /// as an ordinary application.
258    fn head_kind(&self, name: &str) -> Option<HeadKind>;
259    /// A frame opens: a function body, a `let`, a `catch`, a test body.
260    fn open(&mut self);
261    /// `name` is bound in the innermost open frame.
262    fn bind(&mut self, name: &str, span: Span, kind: BinderKind);
263    /// The innermost frame closes.
264    fn close(&mut self);
265    /// A symbol in code position. `opaque`: inside a macro call's arguments,
266    /// which are syntax the macro may never evaluate.
267    fn reference(&mut self, node: &Spanned, name: &str, opaque: bool);
268    fn call(&mut self, node: &Spanned, _whole: &Spanned, name: &str, opaque: bool) {
269        self.reference(node, name, opaque);
270    }
271    fn place(&mut self, node: &Spanned, name: &str, opaque: bool) {
272        self.reference(node, name, opaque);
273    }
274    /// The head of a special form or a macro call — not a reference to a
275    /// binding, but still a name the program writes.
276    fn head(&mut self, _node: &Spanned, _name: &str, _kind: HeadKind) {}
277    /// A symbol inside a quasiquoted template, outside any `unquote`: data at
278    /// expansion, code once the expansion runs.
279    fn template_symbol(&mut self, _node: &Spanned, _name: &str) {}
280}
281
282/// Walk a whole program. Top-level definitions are the pass's to know (they
283/// are globals, see [`definitions_of`]); the walker opens frames only below
284/// them.
285pub fn walk_program(forms: &[Spanned], pass: &mut dyn Scopes) {
286    for form in forms {
287        walk_top(form, pass);
288    }
289}
290
291/// Walk one top-level form.
292pub fn walk_top(form: &Spanned, pass: &mut dyn Scopes) {
293    Walker { pass, depth: 0 }.walk(form, false);
294}
295
296/// The names `form` defines into the frame it is evaluated in, with the
297/// span of each name and whether it is a value or a macro.
298///
299/// A `define` binds in the environment it is EVALUATED in, and every part of
300/// a form is evaluated in the enclosing environment except the parts of a
301/// frame-opening form (`lambda`, a function `define`, `let`, a `catch`
302/// clause, a test body) and quoted data. So this descends into every
303/// sub-form but those: `x = if c … else y = 1 … end` binds `y` in the
304/// function's frame, which is where a later `fn(s) … y … end` reads it.
305#[must_use]
306pub fn definitions_of(form: &Spanned) -> Vec<(String, Span, HeadKind)> {
307    let mut out = Vec::new();
308    definition_nodes(form, &mut |node, kind| {
309        if let Some(n) = node.as_symbol() {
310            out.push((n.to_string(), node.span, kind));
311        }
312    });
313    out
314}
315
316/// [`definitions_of`], handing over the NAME NODE of each definition, so a
317/// pass that rewrites definitions can find the symbol it renames.
318pub fn definition_nodes<'a>(form: &'a Spanned, out: &mut dyn FnMut(&'a Spanned, HeadKind)) {
319    let Some(items) = form.as_list() else { return };
320    let head = items.first().and_then(Spanned::as_symbol);
321    match head {
322        Some("defmacro") => {
323            if let Some(n) = items.get(1).filter(|n| n.as_symbol().is_some()) {
324                out(n, HeadKind::Macro);
325            }
326        }
327        Some("define" | "define-typed") => {
328            if let Some(n) = defined_name_node(items) {
329                out(n, HeadKind::Value);
330            }
331            // `(define x e)`: `e` is evaluated right here, so its defines are
332            // this frame's too. A function define opens a frame; stop.
333            if items.get(1).is_some_and(|t| t.as_symbol().is_some()) {
334                for item in &items[2..] {
335                    definition_nodes(item, out);
336                }
337            }
338        }
339        Some("lambda" | "let" | "let*" | "letrec" | "deftest" | "quote" | "quasiquote") => {}
340        Some("try") => {
341            // The body shares the frame; a `catch` clause opens its own.
342            for item in &items[1..] {
343                let is_clause = item
344                    .as_list()
345                    .and_then(|c| c.first())
346                    .and_then(Spanned::as_symbol)
347                    .is_some_and(|h| h == "catch" || h == "finally");
348                if !is_clause {
349                    definition_nodes(item, out);
350                }
351            }
352        }
353        _ => {
354            for item in items {
355                definition_nodes(item, out);
356            }
357        }
358    }
359}
360
361#[must_use]
362pub fn nullary_definition(form: &Spanned) -> Option<&str> {
363    let items = form.as_list()?;
364    let head = items.first()?.as_symbol()?;
365    if head != "define" && head != "define-typed" {
366        return None;
367    }
368    match items.get(1)?.as_list()? {
369        [name] => name.as_symbol(),
370        _ => None,
371    }
372}
373
374/// The name node a `define`/`define-typed` form binds.
375#[must_use]
376pub fn defined_name_node(items: &[Spanned]) -> Option<&Spanned> {
377    let head = items.first()?.as_symbol()?;
378    if head != "define" && head != "define-typed" {
379        return None;
380    }
381    let target = items.get(1)?;
382    if target.as_symbol().is_some() {
383        return Some(target);
384    }
385    let name = target.as_list()?.first()?;
386    name.as_symbol().map(|_| name)
387}
388
389/// A lambda parameter list: symbols, skipping `&rest`/`&optional`/`.`
390/// markers; a bare symbol is a variadic parameter.
391#[must_use]
392pub fn params_of(list: &Spanned) -> Vec<(String, Span)> {
393    if let Some(n) = list.as_symbol() {
394        return vec![(n.to_string(), list.span)];
395    }
396    list.as_list()
397        .map(|ps| ps.iter().filter_map(param).collect())
398        .unwrap_or_default()
399}
400
401fn param(p: &Spanned) -> Option<(String, Span)> {
402    let n = p.as_symbol()?;
403    if n.starts_with('&') || n == "." {
404        return None;
405    }
406    Some((n.to_string(), p.span))
407}
408
409struct Walker<'p> {
410    pass: &'p mut dyn Scopes,
411    /// Open frames. Zero at the top level of a form.
412    depth: usize,
413}
414
415impl Walker<'_> {
416    fn open(&mut self) {
417        self.depth += 1;
418        self.pass.open();
419    }
420
421    fn close(&mut self) {
422        self.depth -= 1;
423        self.pass.close();
424    }
425
426    /// Bind, in the current frame, every `define` a body reaches without
427    /// crossing a frame boundary — so a body can read a local defined later in
428    /// it, as a closure over the frame can at runtime.
429    fn hoist(&mut self, body: &[Spanned]) {
430        for f in body {
431            let function = f.as_list().is_some_and(|items| {
432                items.first().and_then(Spanned::as_symbol) == Some("define")
433                    && items.get(1).is_some_and(|t| t.as_list().is_some())
434            });
435            let pass = &mut *self.pass;
436            definition_nodes(f, &mut |node, kind| {
437                let Some(name) = node.as_symbol() else { return };
438                let bk = match kind {
439                    HeadKind::Value if !function => BinderKind::Assignment,
440                    _ => BinderKind::Function,
441                };
442                pass.bind(name, node.span, bk);
443            });
444        }
445    }
446
447    /// Walk a body: a new frame, its parameters bound, its defines hoisted,
448    /// each form walked.
449    fn body(&mut self, params: &[(String, Span)], forms: &[Spanned], opaque: bool) {
450        self.open();
451        for (p, s) in params {
452            self.pass.bind(p, *s, BinderKind::Parameter);
453        }
454        self.hoist(forms);
455        for f in forms {
456            self.walk(f, opaque);
457        }
458        self.close();
459    }
460
461    fn walk(&mut self, form: &Spanned, opaque: bool) {
462        match &form.form {
463            SpannedForm::Atom(Atom::Symbol(n)) => self.pass.reference(form, n, opaque),
464            SpannedForm::Quote(_) => {}
465            SpannedForm::Quasiquote(inner) => self.walk_template(inner, opaque),
466            SpannedForm::Unquote(inner) | SpannedForm::UnquoteSplice(inner) => {
467                self.walk(inner, opaque);
468            }
469            SpannedForm::List(items) if !items.is_empty() => self.walk_list(form, items, opaque),
470            _ => {}
471        }
472    }
473
474    /// Inside a quasiquote only the unquoted parts are code.
475    fn walk_template(&mut self, form: &Spanned, opaque: bool) {
476        match &form.form {
477            SpannedForm::Atom(Atom::Symbol(n)) => self.pass.template_symbol(form, n),
478            SpannedForm::Unquote(inner) | SpannedForm::UnquoteSplice(inner) => {
479                self.walk(inner, opaque);
480            }
481            SpannedForm::List(items) => {
482                for i in items {
483                    self.walk_template(i, opaque);
484                }
485            }
486            SpannedForm::Quasiquote(inner) => self.walk_template(inner, opaque),
487            _ => {}
488        }
489    }
490
491    fn walk_all(&mut self, items: &[Spanned], opaque: bool) {
492        for i in items {
493            self.walk(i, opaque);
494        }
495    }
496
497    fn walk_list(&mut self, form: &Spanned, items: &[Spanned], opaque: bool) {
498        let Some(head) = items[0].as_symbol() else {
499            self.walk_all(items, opaque);
500            return;
501        };
502        // `deftest` is the test harness's declaration, not a special form: a
503        // body, run in a frame of its own.
504        if head == "deftest" && self.depth == 0 {
505            if let Some(body) = items.get(2) {
506                self.body(&[], std::slice::from_ref(body), opaque);
507            }
508            return;
509        }
510        // `use("x", [:a])` at the top level is a declaration the resolver
511        // consumed, not a call: nothing in it is a reference.
512        if (head == "use" || head == LEGACY_NAMES) && self.depth == 0 {
513            self.pass.head(&items[0], head, HeadKind::SpecialForm);
514            return;
515        }
516        // `define-typed` is erased before evaluation; its shape is `define`'s
517        // with each parameter written `(name Type)`.
518        if head == "define" || head == "define-typed" {
519            self.pass.head(&items[0], head, HeadKind::SpecialForm);
520            self.walk_define(head, items, opaque);
521            return;
522        }
523        // `defmacro` is registered by the expander before evaluation, not
524        // dispatched as a special form, so no arbiter claims it as a head.
525        // `(defmacro name (params) body…)`.
526        if head == "defmacro" && self.depth == 0 {
527            self.pass.head(&items[0], head, HeadKind::SpecialForm);
528            let params = items.get(2).map(params_of).unwrap_or_default();
529            self.body(&params, items.get(3..).unwrap_or(&[]), opaque);
530            return;
531        }
532        match self.pass.head_kind(head) {
533            Some(HeadKind::SpecialForm) => {
534                self.pass.head(&items[0], head, HeadKind::SpecialForm);
535                self.walk_special(head, items, opaque);
536            }
537            Some(HeadKind::Macro) => {
538                // Opaque: a macro's arguments are syntax.
539                self.pass.head(&items[0], head, HeadKind::Macro);
540                for i in &items[1..] {
541                    self.walk(i, true);
542                }
543            }
544            Some(HeadKind::Value) | None => {
545                self.pass.call(&items[0], form, head, opaque);
546                self.walk_all(&items[1..], opaque);
547            }
548        }
549    }
550
551    fn walk_define(&mut self, head: &str, items: &[Spanned], opaque: bool) {
552        let Some(target) = items.get(1) else { return };
553        if target.as_symbol().is_some() {
554            // `(define x e)`: `x` is already bound (hoisted, or a global).
555            self.walk_all(&items[2..], opaque);
556            return;
557        }
558        let Some(sig) = target.as_list() else {
559            self.walk_all(&items[1..], opaque);
560            return;
561        };
562        let params: Vec<(String, Span)> = sig
563            .iter()
564            .skip(1)
565            .filter_map(|p| {
566                if head == "define-typed" && p.as_list().is_some() {
567                    let n = p.as_list()?.first()?;
568                    Some((n.as_symbol()?.to_string(), n.span))
569                } else {
570                    param(p)
571                }
572            })
573            .collect();
574        // `(define-typed sig R body)`: the return type is not code.
575        let body_from = if head == "define-typed" { 3 } else { 2 };
576        self.body(&params, items.get(body_from..).unwrap_or(&[]), opaque);
577    }
578
579    fn walk_special(&mut self, head: &str, items: &[Spanned], opaque: bool) {
580        let args = &items[1..];
581        match head {
582            "quote" | "provide" | "require" => {}
583            "quasiquote" => {
584                for a in args {
585                    self.walk_template(a, opaque);
586                }
587            }
588            "lambda" => {
589                let params = args.first().map(params_of).unwrap_or_default();
590                self.body(&params, args.get(1..).unwrap_or(&[]), opaque);
591            }
592            "cond" => {
593                for clause in args {
594                    match clause.as_list() {
595                        Some(parts) => {
596                            let rest = match parts.first().and_then(Spanned::as_symbol) {
597                                Some("else") => &parts[1..],
598                                _ => parts,
599                            };
600                            for p in rest {
601                                if p.as_symbol() != Some("=>") {
602                                    self.walk(p, opaque);
603                                }
604                            }
605                        }
606                        None => self.walk(clause, opaque),
607                    }
608                }
609            }
610            "let" | "let*" | "letrec" => self.walk_let(head, args, opaque),
611            "set!" => match args.split_first() {
612                Some((target, rest)) if target.as_symbol().is_some() => {
613                    self.pass
614                        .place(target, target.as_symbol().expect("matched"), opaque);
615                    self.walk_all(rest, opaque);
616                }
617                _ => self.walk_all(args, opaque),
618            },
619            "try" => {
620                for a in args {
621                    let clause = a.as_list();
622                    match clause.and_then(|c| c.first()).and_then(Spanned::as_symbol) {
623                        Some("catch") => {
624                            let c = clause.expect("matched");
625                            let var = c.get(1).and_then(|v| {
626                                v.as_list()
627                                    .and_then(|l| l.first())
628                                    .and_then(|s| s.as_symbol().map(|n| (n.to_string(), s.span)))
629                                    .or_else(|| v.as_symbol().map(|n| (n.to_string(), v.span)))
630                            });
631                            self.open();
632                            if let Some((n, s)) = var {
633                                self.pass.bind(&n, s, BinderKind::Catch);
634                            }
635                            self.walk_all(c.get(2..).unwrap_or(&[]), opaque);
636                            self.close();
637                        }
638                        Some("finally") => {
639                            let c = clause.expect("matched");
640                            self.walk_all(&c[1..], opaque);
641                        }
642                        _ => self.walk(a, opaque),
643                    }
644                }
645            }
646            // if, when, unless, and, or, not, begin, set!, delay, eval,
647            // macroexpand, macroexpand-1: every part is an expression.
648            _ => self.walk_all(args, opaque),
649        }
650    }
651
652    fn walk_let(&mut self, head: &str, args: &[Spanned], opaque: bool) {
653        // Named let: `(let name ((a e) …) body…)`.
654        let (name, rest) = match args.first() {
655            Some(n) if n.as_symbol().is_some() && head == "let" => (Some(n), &args[1..]),
656            _ => (None, args),
657        };
658        let Some(bindings) = rest.first().and_then(Spanned::as_list) else {
659            self.walk_all(rest, opaque);
660            return;
661        };
662        let pairs: Vec<(String, Span, Option<&Spanned>)> = bindings
663            .iter()
664            .filter_map(|b| match b.as_list() {
665                Some(pair) => {
666                    let n = pair.first()?;
667                    Some((n.as_symbol()?.to_string(), n.span, pair.get(1)))
668                }
669                None => b.as_symbol().map(|n| (n.to_string(), b.span, None)),
670            })
671            .collect();
672        let body = rest.get(1..).unwrap_or(&[]);
673        if head == "let" {
674            // Inits see the OUTER scope: walk them before the frame opens.
675            for (_, _, init) in &pairs {
676                if let Some(e) = init {
677                    self.walk(e, opaque);
678                }
679            }
680        }
681        self.open();
682        match head {
683            "let" => {
684                if let Some(n) = name {
685                    self.pass.bind(
686                        n.as_symbol().expect("matched"),
687                        n.span,
688                        BinderKind::Function,
689                    );
690                }
691                for (n, s, _) in &pairs {
692                    self.pass.bind(n, *s, BinderKind::Let);
693                }
694            }
695            "let*" => {
696                for (n, s, init) in &pairs {
697                    if let Some(e) = init {
698                        self.walk(e, opaque);
699                    }
700                    self.pass.bind(n, *s, BinderKind::Let);
701                }
702            }
703            _ => {
704                for (n, s, _) in &pairs {
705                    self.pass.bind(n, *s, BinderKind::Let);
706                }
707                for (_, _, init) in &pairs {
708                    if let Some(e) = init {
709                        self.walk(e, opaque);
710                    }
711                }
712            }
713        }
714        self.hoist(body);
715        self.walk_all(body, opaque);
716        self.close();
717    }
718}