No description
  • Go 64.1%
  • Clojure 32.6%
  • Shell 0.9%
  • Python 0.8%
  • Makefile 0.6%
  • Other 1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Matt Parrett 0911118611
feat(lg): split debug info from emitted bytecode (#624)
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>
2026-09-04 06:21:33 -07:00
.agents/skills/docs-status docs: add docs-status agent skill + Claude Code discovery shim (#346) 2026-06-27 17:38:26 -07:00
.claude/skills docs: add docs-status agent skill + Claude Code discovery shim (#346) 2026-06-27 17:38:26 -07:00
.github/workflows build: gate untagged builds against the optional heavy subsystems (#789) 2026-09-02 17:01:03 -04:00
.vscode fix(vscode): enable Git integration (#746) 2026-08-17 14:41:03 -04:00
benchmark bench: compare against a pinned-release baseline (#655) 2026-08-10 20:33:08 -07:00
Casks Brew cask update for let-go version v1.11.0 (#350) 2026-06-28 22:41:06 +01:00
cmd feat(lg): split debug info from emitted bytecode (#624) 2026-09-04 06:21:33 -07:00
docs feat(lg): split debug info from emitted bytecode (#624) 2026-09-04 06:21:33 -07:00
examples feat(aot): report multi-arity entries and group the frame's imports (#692) 2026-09-03 14:00:04 -07:00
HomebrewFormula Migrate Homebrew release to casks 2026-05-18 11:42:57 +01:00
internal test(aot): prove native-entry lowering executes as generated Go, and fix the frame defect it found (#729) 2026-08-13 17:34:17 -04:00
meta Change copyright headers to SPDX format 2021-11-30 20:04:09 +01:00
pkg feat(lg): split debug info from emitted bytecode (#624) 2026-09-04 06:21:33 -07:00
scripts build: gate untagged builds against the optional heavy subsystems (#789) 2026-09-02 17:01:03 -04:00
test feat(lg): split debug info from emitted bytecode (#624) 2026-09-04 06:21:33 -07:00
wasm docs: use tap install command for Homebrew (#351) 2026-06-28 23:03:13 +01:00
.gitattributes chore(git): recompute generated.sums on merge (dedicated 'sums' driver) (#375) 2026-07-06 09:33:54 -04:00
.gitignore build: move build outputs into build/, promote lg into bin/ (#733) 2026-08-16 16:02:03 -07:00
.gitmodules Add Clojure test suite integration and symbolic value handling 2026-04-19 12:49:16 +01:00
.golangci.yml chore: prepare v1.9.0 release (#130) 2026-05-31 09:20:10 +01:00
.goreleaser.yml ci(release): isolate slow lowering e2e tests in a dedicated step (#347) 2026-06-27 23:23:12 -07:00
.pre-commit-config.yaml build: bench-ratchet infrastructure — deterministic rebaseline, Go 1.26.5 baseline, pre-push gate (#780) 2026-09-02 16:37:08 -04:00
CONTRIBUTORS feat(args): add core/*command-line-args* var (#205) 2026-06-09 00:32:59 +01:00
deps.edn feat(gogen): make the gogen macro layer usable by the IR lowering pipeline (#286) 2026-06-21 22:57:39 -04:00
Dockerfile Add a Dockerfile 2023-03-21 14:24:57 +00:00
go.mod build: single-source Go toolchain via go.mod toolchain directive (#677) 2026-08-10 20:33:32 -07:00
go.sum chore: prepare v1.9.0 release (#130) 2026-05-31 09:20:10 +01:00
lg.go feat(lg): split debug info from emitted bytecode (#624) 2026-09-04 06:21:33 -07:00
lg_ansi.go chore: migrate golangci-lint to v2 (#82) 2026-05-24 01:11:51 +01:00
lg_ansi_plan9.go Add plan9 port and release pipeline 2026-05-07 14:57:36 +01:00
lg_profile.go feat(lg): LG_CPUPROFILE/LG_MEMPROFILE env fallback for profiling builds (#331) 2026-06-25 01:31:54 -04:00
lg_profile_default.go feat(lg): add -cpuprofile and -memprofile flags 2026-06-23 08:17:54 -07:00
lg_repl.go fix(vm,repl): stop tab completion crashing on the cyclic refer graph (#710) 2026-08-10 18:59:40 -07:00
lg_repl_stub.go fix(vm): recover top-level lazy realization errors (#491) 2026-07-16 10:14:43 -04:00
LICENSE Update copyright years and improve example files 2026-03-28 19:19:19 +00:00
Makefile build: gate untagged builds against the optional heavy subsystems (#789) 2026-09-02 17:01:03 -04:00
mise.toml build: single-source Go toolchain via go.mod toolchain directive (#677) 2026-08-10 20:33:32 -07:00
README.md feat(lg): split debug info from emitted bytecode (#624) 2026-09-04 06:21:33 -07:00
tap_migrations.json Migrate Homebrew release to casks 2026-05-18 11:42:57 +01:00
wasm.go refactor(build): extract the generated-module scaffolding into pkg/gomod (#741) 2026-08-16 15:38:50 -07:00

Squishy loafer

Tests

let-go

💬 Come talk about let-go in #let-go on 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 syscall namespace

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

🤓 Follow me on X 🐬 Check out monk.io