let-go

command module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 29, 2026 License: MIT Imports: 11 Imported by: 0

README

Squishy loafer

Tests

let-go

Greetings loafers! (λ-gophers haha, get it?)

This is a bytecode compiler and VM for a language closely resembling Clojure, a Clojure dialect, if you will. Ships as a single ~9MB binary with ~12ms startup time.

Here are some nebulous goals in no particular order:

  • Quality entertainment,
  • Making it legal to write Clojure at your Go dayjob,
  • Implement as much of Clojure as possible — including persistent data types, true concurrency, transducers, core.async, and BigInts,
  • Provide comfy two-way interop for arbitrary functions and types,
  • AOT (let-go -> standalone binary) would be nice eventually,
  • Stretch goal: let-go bytecode -> Go translation.

Here are the non goals:

  • Stellar performance (cough cough, it seems to be way faster than Joker),
  • Being a drop-in replacement for clojure/clojure at any point,
  • Being a linter/formatter/tooling for Clojure in general.

Feature overview

Language
  • Macros with syntax-quote, unquote, unquote-splicing
  • Destructuring (sequential, associative, :keys, :as, :or)
  • Multi-arity and variadic functions
  • loop/recur with tail-call optimization
  • try/catch/finally, throw, ex-info
  • letfn for mutual recursion between local functions
  • Dynamic variables with binding
  • Lazy sequences (lazy-seq, iterate, repeat, cycle)
  • Transducers (map, filter, take, drop, partition-by, etc. all return transducers with 1-arity)
  • transduce, into with xform, completing, sequence, cat, dedupe
  • Protocols and extend-type / extend-protocol
  • Records with defrecord
  • Multimethods with defmulti / defmethod
  • Regular expressions (Go flavor)
  • Metadata on collections and vars
Data structures
  • Persistent hash maps (HAMT), vectors, sets
  • Transient collections for efficient batch building
  • delay / force, promise / deliver
  • atom with watches, volatile! for unsynchronized mutation
  • reduced for early termination in reduce/transduce
Concurrency (async namespace)
  • go blocks and go-loop — goroutine-based lightweight concurrency
  • Channels with optional buffering, <!, >!, close!
  • alts! — select on multiple channel operations with timeout support
  • offer! / poll! — non-blocking channel ops
  • mult / tap / untap — broadcast
  • pub / sub / unsub — topic-based routing
  • merge, pipe, split, async/map, async/take
  • to-chan!, onto-chan!, async/into, async/reduce
  • promise-chan, timeout
IO & Networking (io namespace)
  • Protocol-based reader/writer coercion (IReadable, IWritable)
  • io/reader, io/writer — polymorphic (strings as paths, handles, buffers, URLs)
  • io/line-seq — lazy line-by-line reading
  • io/buffer — mutable byte buffers
  • io/copy, io/slurp, io/spit, io/read-lines, io/write-lines
  • io/url — parsed URL records, readable via protocol (HTTP GET)
  • Encoding: io/encode / io/decode (:base64, :hex, :url)
  • Handle-based file IO: open, close!, read-line, write!, read-bytes
  • with-open macro for auto-closing resources
  • *in*, *out*, *err* — stdin/stdout/stderr
HTTP (http namespace)
  • Ring-style HTTP server (http/serve)
  • HTTP client: http/get, http/post, http/request
  • Streaming responses with :as :stream
  • URL records accepted in all client functions
JSON (json namespace)
  • json/read-json, json/write-json
  • Proper float preservation, PersistentMap/Vector support, record serialization
Transit (transit namespace)
  • transit/read, transit/write - transit+json codec
  • Full rolling cache support for compact encoding
  • Keywords, symbols, maps, vectors, sets, lists, big integers
OS (os namespace)
  • os/sh - run shell commands, capture stdout/stderr/exit code
  • os/stat, os/ls, os/cwd, os/getenv, os/setenv, os/exit
Babashka pods

let-go supports Babashka pods - standalone programs that expose namespaces over a binary protocol. This gives let-go access to the entire pod ecosystem: databases, AWS, Docker, file watching, and more.

;; Load a pod (uses babashka's shared cache)
(pods/load-pod 'org.babashka/go-sqlite3 "0.3.13")

;; Use it like any other namespace
(pod.babashka.go-sqlite3/execute! "app.db"
  ["create table users (id integer primary key, name text)"])
(pod.babashka.go-sqlite3/execute! "app.db"
  ["insert into users values (1, ?)" "Alice"])
(pod.babashka.go-sqlite3/query "app.db"
  ["select * from users"])
;; => [{:id 1 :name "Alice"}]
  • pods/load-pod - load by name (PATH) or from babashka cache (symbol + version)
  • Supports JSON, EDN, and transit+json payload formats
  • Client-side code evaluation (pod-defined macros and wrappers)
  • Async streaming via pods/invoke with :handlers for callbacks
  • Shares ~/.babashka/pods/ cache - install pods with bb, use them from lg

See the pod registry for available pods. Install pods with babashka:

bb -e '(pods/load-pod (quote org.babashka/go-sqlite3) "0.3.13")'
Go interop
  • RegisterStruct[T] — map Go structs to let-go records with cached field converters
  • ToRecord[T] / ToStruct[T] — zero-cost roundtrip for unmutated records
  • BoxValue auto-converts registered structs to records
  • Boxed Go values expose methods via .method interop syntax
  • .field access on records
Core library

Comprehensive clojure.core coverage including: comp, partial, juxt, complement, constantly, memoize, trampoline, map, filter, reduce, mapcat, keep, take, drop, take-while, drop-while, group-by, frequencies, partition, partition-by, interpose, interleave, flatten, distinct, dedupe, sort-by, merge-with, select-keys, update-in, get-in, assoc-in, tree-seq, cycle, doall, dorun, pmap, future, promise, deliver, add-watch, remove-watch, subvec, compare, not-any?, not-every?, doto, fn?, replace, nthrest, nthnext, bit-and, bit-or, bit-xor, bit-not, bit-shift-left, bit-shift-right, re-find, re-matches, re-seq, re-groups, and many more.

Additional namespaces: string, set, walk, edn, pprint, test, transit, pods.

Benchmarks

Benchmarks compare let-go against Babashka (GraalVM native), Joker (Go tree-walk interpreter), and Clojure on the JVM. Each benchmark is valid Clojure that runs unmodified on all runtimes. Run benchmark/run.sh to reproduce (requires hyperfine, bb, clj, joker).

let-go babashka joker clojure JVM
Platform Go bytecode VM GraalVM native Go tree-walk interpreter JVM (HotSpot)
Binary size 9.2M 68M 26M 304M (JDK)
Startup 12ms 21ms 11ms 346ms
Idle memory 16MB 27MB 21MB 93MB

Performance highlights (Apple M1 Pro):

  • Smallest footprint - 7x smaller than Babashka, 33x smaller than the JDK
  • Fastest startup - 12ms, neck and neck with Joker, 1.8x faster than Babashka, 30x faster than JVM
  • Wins on short-lived tasks - map/filter and transducer pipelines: 13ms vs bb's 21ms (startup dominates)
  • Competitive on compute - fib(35) within 4% of Babashka (2.0s vs 1.9s), loop-recur neck and neck
  • Lowest memory - 17MB for fib(35) vs bb's 77MB (4.5x less), 21MB for reduce 1M vs bb's 59MB (2.8x less)
  • 10x faster than Joker on all compute benchmarks - bytecode VM vs tree-walk interpreter

Full results with methodology: benchmark/results.md

Known limitations and divergence from Clojure

Not implemented
  • Sorted collections (sorted-map, sorted-set)
  • Refs / STM — atoms + channels cover practical concurrency needs
  • Agents — use go blocks and channels instead
  • Chunked sequences — lazy seqs are unchunked (simpler, slightly different perf characteristics)
  • Reader tagged literals (#inst, #uuid)
  • deftype — use defrecord instead
  • reify — protocols can only be extended to named types
  • Spec — no clojure.spec
  • alter-var-root — vars are mutable but no alter-var-root
Known behavioral differences
  • concat* (used internally by quasiquote) is eager — the user-facing concat is lazy, matching Clojure
  • All channel operations block<! and <!! are identical (Go channels are always blocking), same for >!/>!!
  • go blocks are real goroutines — no IOC (inversion of control) state machine like Clojure's core.async; this means they're cheaper but go blocks can call blocking ops directly
  • No BigDecimal — numeric tower is int64 + float64 + BigInt (no arbitrary-precision decimals)
  • Regex is Go flavorre2 syntax, not Java regex
  • letfn uses atoms internally for forward references — slight overhead vs Clojure's direct binding

Examples

See:

  • Examples for small programs
  • Tests for comprehensive .lg test files covering all features

Try online

Check out this bare-bones online REPL. It runs a WASM build of let-go in your browser!

Installation

Homebrew (macOS / Linux)
brew tap nooga/let-go https://github.com/nooga/let-go
brew install let-go
Download binary

Grab a prebuilt binary from Releases — available for Linux, macOS, and Windows on amd64/arm64.

From source

Requires Go 1.22+.

go install github.com/nooga/let-go@latest
Usage
lg                                 # REPL
lg -e '(+ 1 1)'                   # eval expression
lg myfile.lg                       # run file
lg -r myfile.lg                    # run file, then REPL
Building from source
go run .                           # run from source
go build -ldflags="-s -w" -o lg .  # ~9MB stripped binary

nREPL

let-go includes an nREPL server compatible with CIDER (Emacs), Calva (VS Code), and Conjure (Neovim).

lg -n                              # start nREPL on default port (2137)
lg -n -p 7888                      # start nREPL on port 7888

The server writes .nrepl-port in the current directory so editors auto-discover it.

Supported ops: clone, close, eval, load-file, describe, completions, complete, info, lookup, ls-sessions, interrupt

Emacs (CIDER): M-x cider-connect-clj, host localhost, port from .nrepl-port

VS Code (Calva): Open a let-go project — the included .vscode/settings.json registers a custom connect sequence. Use "Calva: Start a Project REPL and Connect (Jack-In)" and pick "let-go", or "Calva: Connect to a Running REPL Server" if the nREPL is already running.

Neovim (Conjure): Should auto-connect when .nrepl-port exists

Embedding in Go

import (
    "github.com/nooga/let-go/pkg/api"
    "github.com/nooga/let-go/pkg/vm"
)

c, _ := api.NewLetGo("myapp")

// Define Go values in let-go
c.Def("x", 42)
c.Def("greet", func(name string) string {
    return "Hello, " + name
})

// Run let-go code
v, _ := c.Run(`(greet "world")`)
fmt.Println(v) // "Hello, world"

// Struct <-> Record interop
type Point struct { X, Y int }
vm.RegisterStruct[Point]("myapp/Point")
c.Def("p", Point{3, 4})
v, _ = c.Run(`(:x p)`) // 3

Testing

go test ./... -count=1 -timeout 30s

🤓 Follow me on twitter 🐬 Check out monk.io

Documentation

The Go Gopher

There is no documentation for this package.

Directories

Path Synopsis
pkg
api
rt
vm

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL