- Go 64.1%
- Clojure 32.6%
- Shell 0.9%
- Python 0.8%
- Makefile 0.6%
- Other 1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Source maps and local-variable tables are the largest removable sections of an emitted bundle, and execution never consults them — but simply discarding them makes a production failure impossible to trace back to source. Treat stripping as splitting debug information instead: `lg -strip` (for both -c and -b) writes a smaller runtime artifact alongside a companion holding what was removed. `lg -strip -c app.lgb app.lg` produces app.lgb and app.lgb.debug; `-debug-output <path>` sends the companion elsewhere. On the fib sample the runtime bundle drops from 1,565 to 1,421 bytes (-9.2%) with a 201-byte companion. The companion is archival: the stripped artifact still runs without it, reporting unlocated frames. bytecode.SplitDebug emits the stripped LGB plus a versioned companion keyed by chunk index, and FlagDebugSplit marks artifacts whose debug info was externalized. The companion stores the SHA-256 of the exact stripped payload and is rejected on mismatch, because a companion silently paired with the wrong build yields tracebacks that are confidently wrong — worse than none. `lg`, standalone bundles, and lg-runtime load a co-located .debug automatically; LG_DEBUG_FILE points at another location, and an empty value disables loading. Scoped to program bytecode from -c and -b. Stripping the embedded core, and extending this to -w/WASI, are follow-ups. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
| .agents/skills/docs-status | ||
| .claude/skills | ||
| .github/workflows | ||
| .vscode | ||
| benchmark | ||
| Casks | ||
| cmd | ||
| docs | ||
| examples | ||
| HomebrewFormula | ||
| internal | ||
| meta | ||
| pkg | ||
| scripts | ||
| test | ||
| wasm | ||
| .gitattributes | ||
| .gitignore | ||
| .gitmodules | ||
| .golangci.yml | ||
| .goreleaser.yml | ||
| .pre-commit-config.yaml | ||
| CONTRIBUTORS | ||
| deps.edn | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| lg.go | ||
| lg_ansi.go | ||
| lg_ansi_plan9.go | ||
| lg_profile.go | ||
| lg_profile_default.go | ||
| lg_repl.go | ||
| lg_repl_stub.go | ||
| LICENSE | ||
| Makefile | ||
| mise.toml | ||
| README.md | ||
| tap_migrations.json | ||
| wasm.go | ||
let-go
💬 Come talk about let-go in
#let-goon The Fixpoint Discord.
Greetings loafers! (λ-gophers haha, get it?)
let-go is a Clojure dialect with a bytecode compiler and stack VM, written in Go. A single ~13MB binary, ~10ms cold start, no JVM. It passes the jank-lang test suite.
I started this in 2021 as an elaborate joke: an excuse to write Clojure while pretending to write Go. It turned out useful. I use it for CLIs, scripts, and web servers, and I built a daemonless container runtime on top of it. You can compile let-go programs to standalone binaries or self-contained WASM web pages. It even runs on Plan 9, and ReMarkable 2.
It is not a drop-in replacement for Clojure JVM. It does not load JARs and does not aim to. Most idiomatic Clojure code runs unmodified, but a real project with library dependencies will need adjustments. See Known limitations below.
Goals (in no particular order)
- Quality entertainment
- Implement most of Clojure: persistent data structures, lazy seqs, transducers, protocols, records, multimethods, core.async, BigInts
- Comfy two-way Go interop (functions, structs, channels)
- AOT compilation to bytecode and standalone binaries
- Boot the runtime inside a single
requestAnimationFrame(10ms left over at 60fps) - Compile programs to self-contained WASM web pages with terminal emulation
- Make it legal to write Clojure at your Go dayjob
- nREPL in the browser (let-go VM in WASM, editor over WebSocket)
- Stretch: let-go bytecode → Go translation
Non-goals: drop-in JVM Clojure replacement; linter/formatter for Clojure-at-large.
Benchmarks
let-go (bytecode VM), let-go AOT (the same code IR-lowered to native Go), Babashka, and Clojure JVM. All benchmark files are valid Clojure that runs unmodified; the VM and AOT legs run the identical program — only dispatch differs. Apple M1 Pro.
| let-go | let-go AOT | babashka | clojure JVM | |
|---|---|---|---|---|
| Binary size | 13MB | 18MB | 68MB | 304MB (JDK) |
| Startup | 11.1ms | 10.7ms | 20.4ms | 364ms |
| Idle memory | 15.2MB | 15.2MB | 27.0MB | 97.7MB |
let-go stays compact and quick to launch: a small native binary, fastest startup in this run, low RSS, and no JVM dependency.
On runtime benchmarks, the VM is competitive on short-lived data work — map/filter (11.3ms) comes in 1.7× ahead of Babashka, and persistent maps (22.2ms) run neck and neck with it — while AOT lowering turns the call-heavy numeric cases around entirely: fib(35) drops from 2.42s to 0.11s and tak from 2.40s to 94ms, an order of magnitude ahead of Babashka. Fully-warmed HotSpot still holds the raw-compute crown (steady-state JVM fib(35) is ~76ms to AOT's ~99ms, measured in-process) but pays ~360ms of startup first, so on one-shot runs let-go AOT delivers the result ~5× sooner. Babashka still leads reduction/transducer workloads.
Full per-benchmark numbers and methodology: benchmark/results.md.
Compatibility
Tested against jank-lang/clojure-test-suite:
5621 / 5621 assertions pass across 232 files through the :clj reader
lens, with no known failures, compile skips, panic skips, or runtime skips.
Core namespaces cover clojure.core (macros, lazy seqs, transducers, protocols,
records, multimethods, BigInt/BigDecimal) plus string, set, walk, edn,
pprint, test, and core.async, alongside let-go's own io, http, json,
transit, os, System, syscall, and pods. See
docs/guide/clojure-compatibility.md for
the full per-namespace status table and the Clojure differences.
Babashka pods
let-go can load Babashka pods, opening up the
whole pod ecosystem (SQLite, AWS, Docker, file watching, …) and sharing
~/.babashka/pods/ with bb.
(pods/load-pod 'org.babashka/go-sqlite3 "0.3.13")
(pod.babashka.go-sqlite3/query "app.db" ["select * from users"])
See docs/guide/pods.md for a full example and the shared pod cache.
Portable code (:lg reader conditionals)
let-go ships namespaces of its own (e.g. let-go.semver) that JVM Clojure can't
load. To keep shared code loadable on both, guard the let-go-only parts behind
:lg reader conditionals in a .cljc file — JVM Clojure skips :lg branches
the same way it skips :cljs:
(ns my.app
#?(:lg (:require [let-go.semver :as semver]))) ; only let-go loads this
The guard is at read time, so a missing namespace never reaches compilation.
See docs/guide/portability.md for the .cljc
resolution rule and :lg/:clj ordering gotcha.
Version requirements (let-go.semver)
let-go.semver provides SemVer values that sort correctly, npm/cargo-style range
matching (satisfies-range? — comparators, x-ranges, ^/~, ||), and
require-letgo, which asserts at load time that the running lg build is new
enough and fails with one clear line instead of a "can't resolve" cascade:
(ns my.app
#?(:lg (:require [let-go.semver :refer [require-letgo]])))
#?(:lg (require-letgo ">=1.9.0")) ; one clear failure line on too-old lg
Guard it behind :lg reader conditionals
so shared .cljc stays JVM-loadable. See
docs/guide/semver.md for the range grammar and
require-letgo's detection/failure semantics.
Known limitations
Not a drop-in JVM Clojure. The main gaps: no coordinated STM or async agents
(ref/agent are atom-backed aliases), no clojure.spec, unchunked lazy seqs,
no custom *data-readers*, no JVM host interop on deftype/reify, and no
subseq/rsubseq range queries. Behavior also differs in places — pragmatic
numeric tower, always-blocking channels, real-goroutine go blocks, and re2
(not Java) regex.
Full list with rationale: docs/guide/clojure-compatibility.md.
Examples
Things written in let-go:
- xsofy: a roguelike that runs in the browser and the terminal from the same source
- lgcr: a daemonless container runtime, built on the
syscallnamespace
In this repo:
Try it online
Bare-bones browser REPL, running a WASM build of let-go.
Install
Homebrew (macOS / Linux)
Primary install command:
brew install nooga/tap/let-go
Download
Prebuilt binaries for Linux, macOS, and Plan 9 in Releases.
From source (Go 1.26+)
go install github.com/nooga/let-go@latest
Usage
lg # REPL
lg -e '(+ 1 1)' # eval expression
lg myfile.lg # run file
lg myfile.lg a b # run file with arguments
lg -r myfile.lg # run file, then REPL
*command-line-args* holds the program's arguments — the positionals after the
script — as a seq of strings, or nil when there are none. It reads the same
whether you run a script or a bundled binary, so you never slice argv by hand:
;; greet.lg — run as `lg greet.lg Alice Bob` or `./greet Alice Bob`
(doseq [name *command-line-args*]
(println "Hello," name))
Compile and distribute
let-go can compile programs to bytecode (.lgb files) and bundle them as
standalone executables.
lg -c app.lgb app.lg # compile to bytecode
lg app.lgb # run bytecode
lg -b myapp app.lg # bundle into a self-contained binary
./myapp # runs anywhere, no lg needed
The standalone binary is a copy of lg with your bytecode appended. Copy it
to another machine and it runs.
Release artifacts can split source maps and local-variable tables into a digest-bound debug companion:
lg -strip -c app.lgb app.lg # writes app.lgb + app.lgb.debug
lg -strip -b myapp app.lg # writes myapp + myapp.debug
Archive the .debug file while distributing only the smaller runtime artifact.
Putting it back beside the artifact restores source-located stack traces
automatically; LG_DEBUG_FILE=/path/to/app.debug selects a companion stored
elsewhere. A companion for a different build is rejected.
lg -w site app.lg # compile to a WASM web app
open site/index.html
The WASM output is a self-contained index.html (~6MB, inlined and gzipped) with
a service worker for the COOP/COEP headers SharedArrayBuffer needs; term-using
programs get full xterm.js terminal emulation.
See docs/guide/usage.md for the *compiling-aot* /
*in-wasm* compile-time vars, more on each output format, and project/dependency
management with lgx.
Resources and source paths
Programs read non-source files (templates, web assets, data) via io/resource,
with roots set by -resource-paths / LG_RESOURCE_PATHS. Bundling with -b
embeds every file under those roots, so a bundled binary is self-contained.
(when-let [r (io/resource "templates/index.html")]
(io/slurp r))
required namespaces resolve against -source-paths / LG_SOURCE_PATHS
(default .). When you set the search path it's taken as the complete list —
the current directory isn't added implicitly.
See docs/guide/resources-and-source-paths.md for path-list syntax, multi-root precedence, embedding behavior, and the empty-value/explicit-only rules.
nREPL
let-go ships an nREPL server that works with CIDER (Emacs), Calva (VS Code), and
Conjure (Neovim). It writes .nrepl-port to the working directory so editors
auto-discover it.
lg -n # default port 2137
lg -n -p 7888
See docs/guide/nrepl.md for supported ops and per-editor connect steps.
Embedding in Go
let-go embeds cleanly as a scripting layer for Go programs: define Go values and functions, hand them to the VM, run user-supplied Clojure against your data. Go structs roundtrip as records, Go channels are first-class let-go channels, and Go functions are callable from let-go.
c, _ := api.NewLetGo("myapp")
c.Def("greet", func(name string) string { return "Hello, " + name })
v, _ := c.Run(`(greet "world")`) // "Hello, world"
See docs/guide/embedding-in-go.md for struct roundtripping, Go-channel interop, and a pointer to the full example set.
Testing
go test ./... -count=1 -timeout 30s
Contributing
After cloning, run make install-hooks once to register the core_compiled.lgb
merge driver (each clone needs this — the config lives in .git/config, which
isn't shared). See
docs/regenerating-generated-artifacts.md
for how generated artifacts are regenerated and kept in sync.
Ever wanted a 20MB pure-Go JS runtime that typechecks and runs TypeScript? Check my other project: https://github.com/nooga/paserati