Skip to content

eval: top-level (do ...) isn't evaluated subform-by-subform (diverges from Clojure) — breaks (do (require 'x) (x/f)) #195

@nnunley

Description

@nnunley

Summary

let-go does not evaluate a top-level (do ...) subform-by-subform the way Clojure does. A top-level do is compiled as a single unit, so a namespace-qualified symbol in a later subform is resolved at compile time — before an earlier subform's require has run. This breaks the common (do (require 'x) (x/f)) idiom (and any top-level do where a later form depends on the compile-time effect of an earlier one), and it diverges from Clojure.

Repro (zero dependencies, two files)

a.lg:

(ns a)
(defn hello [] :hi)
# a) do-wrapped: FAILS
$ lg -e "(do (require 'a) (a/hello))"
error: Can't resolve a/hello in this context
  --> EXPR:1:18
   |
 1 | (do (require 'a) (a/hello))
   |                  ^^^

# b) the SAME two forms, un-wrapped (separate top-level forms): WORKS
$ printf "(require 'a)\n(println (a/hello))\n" > run.lg && lg run.lg
:hi

So it is specifically the do wrapper that forces single-unit compilation; let-go already evaluates separate top-level forms sequentially (run.lg works), it just doesn't extend that to the subforms of a top-level do.

Note: clojure.* stdlib namespaces are embedded and resolve at compile time without require, so they mask this bug — (do (require 'clojure.set) (clojure.set/union #{1} #{2})) returns #{1 2} even though (find-ns 'clojure.set) is still false. You must use a user namespace loaded from disk (like a above) to reproduce.

Expected (Clojure behavior)

Clojure's eval special-cases a top-level do: clojure.lang.Compiler.eval checks whether the form is a seq beginning with do and, if so, recursively evaluates each subform in turn (compile + eval one, then the next), returning the last. So earlier subforms' effects (a require loading a namespace, an in-ns, an intern) are visible when later subforms are compiled. This is also why a macro may expand into (do (defines-X ...) (uses X ...)) at the top level.

;; Clojure (and babashka) — works:
(do (require '[clojure.string :as s]) (s/upper-case "hi"))  ;=> "HI"

let-go gives Can't resolve s/upper-case in this context for the user-namespace equivalent.

Impact

  • REPL / nREPL ergonomics: require-then-use in one submission must be split into separate top-level forms; (do (require ...) (use ...)) — the natural thing to type/send — fails. (Hit this driving the let-go nREPL from an MCP tool.)
  • Macros: a macro that expands to a top-level (do ...) whose later forms depend on an earlier form's compile-time effect won't work the way it does in Clojure.

Workaround

Submit the forms as separate top-level forms (not wrapped in do, let, when, or a fn body — those fail in both let-go and Clojure since they're genuinely one compiled unit). The only thing missing in let-go is Clojure's top-level-do special case.

Version

lg 1.9.0 (9cb2167), macOS arm64.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions