Development
jolt info prints the full task list grouped by purpose. Jolt reads the tasks from bb.edn, so babashka isn't needed. This page adds what each harness drives and the traps that catch first-time runners.
Verify: check, test, and the pre-commit gate
jolt checkrequires every project namespace headlessly and exits 0. It's the fastest way to catch a namespace that doesn't compile.jolt testruns the unit suite: 1270 tests, 7560 assertions on macOS, 7529 on Linux (some asserts are gated by OS, such asdesktop-file-validateandplutil). Nothing outside the process runs; the attach evaluators use in-process fake servers. Pass namespaces for a subset:jolt test jonquil.edit-test.jolt verifyis the pre-commit gate: clj-kondo in report mode, thenjolt testandjolt test:nrepl, both of which must pass.
The live suites:
test:nrepl (and test:nightly), test:clj, test:jank, test:bb, test:basilisp, test:plugins and test:cljs-node
jolt test never touches a child process or a real server, on purpose, so the live suites are separate tasks. test:jank, test:bb, test:basilisp, test:plugins and test:cljs-node print a message and skip, exiting 0, when their tool is missing.
| Task | Runs against | Needs |
|---|---|---|
jolt test:nrepl | a real Jolt child per test, over jonquil.nrepl.child | the jolt-lang/nrepl checkout (where it comes from) |
jolt test:nightly | the same suite on Jolt's vnightly build | curl; macOS (arm64, x86_64) or Linux x86_64 |
jolt test:debugger | the debugger's unit tests, the live Jolt-child break test and the window self-test, on a Jolt with jolt.debug | JONQUIL_JOLT_DEBUG, else jolt-debug on PATH, else jolt once it has jolt.debug; skips without one |
jolt test:clj | a real clojure -M:serve, started by dev/clj_server.clj | Java 17+, clojure CLI, network on a first run |
jolt test:jank | a real jank repl via the app's launcher | JONQUIL_JANK, else jank found as the app does; skips without |
jolt test:bb | a real bb nrepl-server in a temp project | bb on PATH; skips without |
jolt test:basilisp | a real basilisp nrepl-server | JONQUIL_BASILISP, else basilisp on PATH; skips without |
jolt test:plugins | real isolated plugin checks in throwaway jolt children | jolt on the login PATH; skips without |
jolt test:cljs-node | a real clojure plus node, via the app's launcher | clojure and node; skips without; a first run can download ClojureScript and the Closure Compiler |
jolt test:nightly unpacks the build under $XDG_CACHE_HOME/jonquil/jolt-nightly (or ~/.cache/jonquil/jolt-nightly), checks it against the published .sha256, and leaves the jolt on PATH alone. Later runs download again only when the .sha256 changes, and --offline reuses the cache. The nightly goes first on PATH, so the Jolt children use it too.
jolt test:cljs-node has one test that opens a real browser tab; it runs only with JONQUIL_BROWSER_LIVE=1 jolt test:cljs-node.
JONQUIL_JOLT, set to an executable, is the Jolt program the Jolt child runs, in place of the jolt on the login PATH.
Jolt v0.8.23 and earlier have no jolt.debug, so on them the debugger's tests (frames, Break on error, evaluating in a frame) take their "not available" path and pass without testing anything: a window self-test row reads break-pauses PASS {:available false}. jolt test:debugger runs them on a Jolt that has it, sets JONQUIL_JOLT for the live child test, and fails when one of them skipped or a debugger row took that path. Run it before merging a change that touches the debugger, the evaluators or the child's middleware. It needs Jolt v0.8.24 or later (jolt-lang/jolt#1474): the child's break also needs jolt.nrepl/*eval-wrapper*, so on an older Jolt the live child break test fails. --no-window leaves out the window self-test for a quicker pass.
jolt mcp:smoke: what it checks, headless, with --window and with --attach
jolt mcp:smoke runs jonquil --mcp over real stdio from the checkout, speaks JSON-RPC from a jolt script, and prints a PASS or FAIL line per check (11): handshake, tools, an evaluation, a background println that must not reach stdout, a bracket check, a {:runtime :nrepl} project starting a real Jolt child, and a clean exit once stdin closes. Responses have deadlines, so a hang fails a check. --bin ./jonquil runs it against the binary jolt app:build made, which proves jolt build links the MCP namespaces. The nREPL step uses a jolt-lang/nrepl checkout if present, otherwise fetches v0.4.2 as a git dependency (git and network, first time).
--window opens a real window (don't type while it runs) and checks the handshake and listChanged, the twenty window tools plus a startup plugin's tool, open/Run/edit/save, a runaway evaluation that must time out within 10 s, the plugin tools, and the EOF path: closing stdin with an unsaved edit must end the process within 20 s, leave no Jolt child, and leave the file unchanged. 25 checks; --bin PATH works too.
--attach starts a real window with :mcp-listen? true under a temp XDG_CONFIG_HOME (don't type) and runs jonquil --mcp-attach against it. It checks the twenty window tools, an evaluation, that closing stdin ends the bridge within 10 s with the window still running, and that a second bridge attaches. It ends with SIGTERM to the window, which must exit within 20 s and remove its registry file. 17 checks.
Self-test and docs: ten windows, one binary, one camera
Five harnesses drive the real GTK front end.
jolt gtk:selftest opens ten real GTK windows in turn and runs the rows against them, calling the same button and key handlers a person would use. The full application window alone has over 400 rows. The windows run in the navy theme, easier on the eyes while they flash past; set JONQUIL_SELFTEST_THEME=light (or another theme name) for a run in a different one. Each row starts on that theme, and the theme rows set the themes they check themselves. JONQUIL_SELFTEST_MAXIMIZE=1 maximizes the window before every row, for a recording of a run.
The ten groups, and what each one covers
Fakes stand in for real toolchains (in process, or as a small script started as a child for jank and node), so only the nREPL group needs a real runtime.
| # | File (dev/) | Covers | Needs |
|---|---|---|---|
| 1 | gtk_selftest.clj | Definitions editor and Interactions console | |
| 2 | gtk_selftest_window.clj | the full window, rows split by feature under gtk_selftest_window/ | |
| 3 | gtk_selftest_nrepl.clj | a {:runtime :nrepl} project with a real Jolt child | the jolt-lang/nrepl checkout (one failing row without it) |
| 4 | gtk_selftest_cljd.clj | ClojureDart attach | fake server fake_cljd_nrepl.clj named by NREPL.lock |
| 5 | gtk_selftest_clj.clj | JVM Clojure attach, including slow-answer rows (its :delays atom) | fake server fake_jvm_nrepl.clj named by .nrepl-port |
| 6 | gtk_selftest_tree.clj | session restore from sessions.edn (two files plus one that's gone) | |
| 7 | gtk_selftest_jank.clj | a .jank project | JONQUIL_JANK set to dev/fake-jank (done by the task) |
| 8 | gtk_selftest_args.clj | launching like a terminal, via jonquil.main/gtk-argv | |
| 9 | gtk_selftest_cljs.clj | shadow-cljs attach | fake server fake_shadow_nrepl.clj named by .shadow-cljs/nrepl.port |
| 10 | gtk_selftest_cljs_node.clj | ClojureScript on node, and the browser target | JONQUIL_CLOJURE = dev/fake-cljs-clojure; JONQUIL_OPEN_URL = dev/fake-open-url, which logs to FAKE_OPEN_URL_LOG |
To run the jank group alone, set JONQUIL_JANK to the absolute path of dev/fake-jank and run jolt -M:selftest-jank.
How the rows run: go blocks, timeouts and the GTK thread
Each row is a core.async go block. gtk-async/run-rows runs them in order, and a per-row timeout records a FAIL instead of hanging the run. An overdue row is abandoned at its next harness call, so it can't act into later rows.
The hard rule: the GTK thread never parks. Every GTK, editor or console call goes through on-gtk or an await-until predicate from dev/gtk_async.clj; a row's body never calls GTK directly. A row that throws is recorded as a FAIL by go-row and the run moves on.
Before each row the drivers clear the editor and console input and wait for the engine to go idle. If it's still busy, gtk-async/ready-for-row? presses Stop and waits once more; if that fails the row is skipped with a :row-skipped-busy FAIL (where Stop can't interrupt, as with shadow-cljs, every later row is skipped). Drivers close their window through gtk-async/close-window-bounded!, which records a FAIL and exits 1 if the window stays open.
jolt smoke:binary builds target/smoke/jonquil (skip with --no-build), launches it as a subprocess, and drives it with cgevent key --pid, so no keystroke goes to other windows or --global. It checks file bytes, window titles, and screen pixels (OCR through cgevent ocr-inspect, plus a colour check of the theme's text and prompt). Every process it starts is killed on every exit path.
jolt smoke:binary, smoke:cljd, smoke:cljs and jolt docs:shots drive the macOS cgevent tool, which is private for now, so they run only where it is installed. The unit suite, the self-tests and jolt linux:test never need it.
jolt smoke:cljd drives a built ./jonquil against a real, running ClojureDart app built on the nREPL fork (private for now). The gitignored dev/smoke/cljd_app.local.edn names it: :dir (absolute path), :main-ns (the namespace Run loads), and optionally :fork-sha (a prefix the app's deps.edn :git/sha must start with). Without the file the task prints how to create it and exits 1. If the app isn't up or the screen is locked, every step SKIPs and it exits 0.
What it does, step by step
One Jonquil process stays open: open drive.cljd, Run and wait for Loaded <main-ns>, read (where), send (next!) and (prev!) (the second only after (where) confirms the first landed), Run Form on a typed let then undo, quick doc and completion, Stop during a slow eval and reconnect, close. It never saves the app's file and never quits, signals or restarts the app, and it compares the app's git status before and after. It treats the buffer as clean only when the title reads exactly drive.cljd - Jonquil. Quit and restart stay manual checks, so those two steps always SKIP. A close running past 3 s saves a screenshot and stack sample under target/smoke/.
jolt smoke:cljs drives a built ./jonquil against a real, running shadow-cljs app. Start the watch and a JS runtime first (npx shadow-cljs watch <build>, then the browser tab or node script); the task never starts or stops them. The gitignored dev/smoke/cljs_app.local.edn holds {:dir "..." :build :dev}, and without it the task prints how to create it. With several builds it writes a temporary untracked .jonquil.edn into the app directory to name one, removed on every exit path. 18 checks.
jolt docs:shots self-captures every docs/images/*.png and the GIFs the guide pages embed, launching a fresh jolt -M:shots <scene-id> per scene (32 scenes). Retake one image with that command directly. First runs can download dependencies (ClojureScript and shadow-cljs scenes, and the ClojureDocs export for clj-full-doc). A scene whose tools are missing prints a line and exits 0 without an image.
The jank, ClojureScript, shadow-cljs, nREPL and JVM Clojure scenes
| Scene | Needs |
|---|---|
jank | a real jank (JONQUIL_JANK, PATH, the usual install folders) |
cljs-node, cljs-browser | clojure CLI (JONQUIL_CLOJURE or login PATH); cljs-node also node; cljs-browser writes {:cljs {:target :browser}} and opens a real tab |
nrepl-bb | bb on the login PATH; starts bb nrepl-server on a free loopback port and names it in .jonquil.edn as {:dialect :nrepl} |
cljs-shadow | clojure CLI, node, npm; runs a real shadow-cljs 3.2.0 watch with a :node-script build, after npm install ws |
clj-attach, clj-quick-doc, clj-full-doc, clj-inspector, clj-source | Java 17+ and clojure; a real clojure -M:serve started as jolt test:clj does |
jolt docs:shots sets JONQUIL_CLOJURE to the bare name clojure for the two cljs scenes (unless you set it) so no home directory lands in the image. Retake one with JONQUIL_CLOJURE=clojure jolt -M:shots cljs-node.
Every harness isolates your settings. The window self-tests and docs:shots pass a temp :settings-path to jonquil.main/start!, and the smokes point XDG_CONFIG_HOME at a fresh temp directory per process. Nothing touches ~/.config/jonquil/settings.edn.
Temp directories come from dev/temp_dirs.clj, which deletes them when the tests or driver finish. jolt clean:tmp removes what older runs left (only names these harnesses make); jolt clean:tmp --dry-run lists them first.
Traps
- Don't type while a self-test or smoke window has focus. A stray keystroke fails an unrelated row.
- A phantom second display at scale 1.0 breaks
smoke:binary's OCR and makesdocs:shotsimages 1x instead of Retina 2x. Checksystem_profiler SPDisplaysDataType. - Never use
cgevent typeagainst this app: GTK's macOS backend reads the key code, not the Unicode string, so text arrives mangled. - An interrupted
jolt test:nreplorjolt gtk:selftestcan leave a Jolt child. Checkpgrep -fl "jolt.nrepl/start 79"and kill only pids from your interrupted run. - An interrupted
jolt test:cljcan leave a JVM. Checkpgrep -fl clojure-cli.repl.server, and kill only a pid that run started.
Adding a feature as a window part
The design is in Window parts. In short:
- Add
src/jonquil/parts/<name>.cljwithspec(its commands, each with a:calldata entry) and list it injonquil.parts/specs. - Add
src/jonquil/gtk/parts/<name>.cljwith(part wctx)tagged^{:part/id :<name>}, returning{:fns :ui :hooks :exports}, and list it injonquil.gtk.parts/installs. - Take everything the part needs from
wctx, never by requiringjonquil.gtk.window. - For a right-hand panel, declare it in the spec's
:panelswith a label, add a:panel/<id>command whose:callis[:panel-focus-fn :<id>], and answer it under:panelsfrom the install:{:<id> {:root widget :focus! f :open! f}}. Don't use:reviewor:history, which are core ids. - For keys, a new feature's keys may go in the spec's
:bindings({:window [...] :editor [...]}, entries shaped likekeymap.edn). Keys of existing features stay inkeymap.edn. A binding with Alt needs:physical? trueto fire on macOS. Spec bindings come after the shipped ones, so a shipped key wins, and theunreachable-bindingstest fails if one is shadowed. - Test the spec in
jolt test, and add a row to a window self-test for the install.specs-and-installs-name-the-same-partsfails if only one list has the part. - For per-tab state, add
:on-tab-removed,:on-tab-wiredand:on-switchhooks to:hooks; each is called(f ctx tab). Wire-time hooks run before the changed-form gutter's handler connects, and removal hooks run while the tab's buffer is still alive, and switch hooks run when a tab becomes the active one. A throwing hook is reported on the status line and the close or wiring goes on. - Run
jolt verify.
The macOS app: bundle, install, icon
jolt app:bundlerebuilds./jonquiland makes./Jonquil.app, printing acheckline forplutil -linton theInfo.plist, the executable bit, andcodesign -dvshowing the linker's ad-hoc signature. A failed check exits non-zero naming it.jolt app:installbundles, moves the result to~/Applications/Jonquil.app(replacing an existing one only if its plist namesnet.b12n.jonquil), and runslsregister -f. The checkout's copy is unregistered and removed, so Open With lists Jonquil once.jolt app:iconregeneratesresources/Jonquil.icnsandresources/icon/jonquil-256.pngfromresources/icon/icon.swift. It needs Swift, and you run it only after changing the icon.
The bundle is deliberately not re-signed: jolt build appends the program image after the launcher's Mach-O, so codesign --force fails strict validation. macOS checks the linker's ad-hoc signature, which the task verifies.
On Linux, jolt app:install runs jolt app:build and copies the binary, a launcher, the icon and the MIME types under ~/.local/share (dev/app_linux.clj); jolt app:uninstall removes them. On macOS it only says to drag the app to the Trash.
Linux checks in Docker
Every Linux check runs in a local Docker container, so a Mac can verify the Linux build. There is no CI for it: the workflows in .github/ stay manual. Docker is the only requirement beyond the usual tools.
| Task | What it does |
|---|---|
jolt linux:image | builds jonquil-linux:<arch> (Ubuntu 26.04, GTK 4, Xvfb, Jolt v0.8.27) |
jolt linux:test [ns...] | jolt test on a copy of the checkout |
jolt linux:selftest | jolt gtk:selftest under Xvfb |
jolt linux:build | jolt app:build; the binary lands in target/linux/<arch>/ |
jolt linux:run [--] cmd... | any command in the copied tree (the -- is optional, since jolt drops the first one) |
jolt linux:vnc | a desktop in the browser for the hand check |
jolt linux:clean | removes both images and target/linux |
Tasks take --arch arm64|amd64 (default: the host's), except linux:clean. The arm64 image compiles Jolt and Chez Scheme from source because Jolt publishes no Linux arm64 binary, so it's slow to build (about 11 minutes); amd64 uses the pinned release tarball but still takes about 6.5 minutes, its apt step emulated. Pulling the base image needs Docker Hub, so run the first jolt linux:image from a normal terminal.
The checkout is mounted read-only and copied to ~/jonquil in the container, skipping jonquil, jonquil.build, Jonquil.app, _site and target. Only target/linux/<arch>/ is written in your tree.
A non-host arch runs under emulation, several times slower. jolt linux:selftest then sets JONQUIL_SELFTEST_EMULATED=1, widening the paint-async row's budget from 150 to 750 ms. The 20 s wait for a Jolt nREPL child can still run out on a busy host.
The noVNC hand check
jolt linux:vnc starts a container with Xvfb, openbox, pcmanfm, x11vnc and noVNC, installs Jonquil with jolt app:install, and prints a URL such as http://localhost:6080/vnc.html?autoconnect=true&resize=scale. The port is the first free one from 6080; --port P asks for one and is refused when busy. In pcmanfm, open ~/smoke (hello.clj, hello.jank, nrepl-project) and check that right-clicking a file offers Jonquil, that it opens, and that selecting both hello files opens one window with two tabs. For anything else use docker exec jonquil-vnc-<P> ... (add -e DISPLAY=:1 for windows). Ctrl-C stops the container and frees the port.
Quality: lint, format, and the git hooks
jolt lintruns clj-kondo oversrc,testanddevin report mode.jolt lint:strictexits non-zero on any finding (2 for warnings, 3 for errors); the lint workflow gates on it. The git hooks block only on clj-kondo errors.jolt metricsruns jolt-metrics over the repo and prints its Markdown report: a refactoring queue worst first, git hotspots, unusual commits and files, unused requires and functions nothing mentions, then per-file numbers and coupling. The queue weighs each function by its reach, how many functions depend on it at any distance (from jolt-lsp's call graph), so a complex function the rest of the code goes through ranks first; the EDN and JSON reports also carry each function's fan-in and fan-out.jolt metrics:edngives every finding as data,jolt metrics:jsona lean JSON report for tools, andjolt metrics:htmlone self-contained page (target/metrics.htmlunless a file comes first). Extra arguments go to its CLI (an output file, whose extension picks the format,--top N,--no-git,--exclude GLOB). Findings never fail it; only bad usage does..jolt-metrics.ednat the root countsdevas test code (measured, never queued) and leaves out build output, clj-kondo's cache, the smoke fixtures anddocs. Upstream-lifted code (src/jonquil/syntax.clj,src/jonquil/parinfer.clj,src/jonquil/inspect/view.cljandtest/jonquil/upstream/) is left out too, since it follows DrClojure's source and is tracked inUPSTREAM.md. The:metricsalias pins the library's version, at the commit Jolt's own CI gate runs.jolt metrics:gateis the one metrics task that fails. It runs the report as EDN throughci/metrics-gate.clj, which checksci/metrics-allow.edn, and fails on two things: - a critical finding undersrc/ortest/: a function past a complexity, length, nesting, parameter, coupling or maintainability limit, or a file past 1000 lines. Undertest/, only helpers and the file itself are queued, notdeftestbodies. A file jolt-metrics can't read there fails it too. There's no list of accepted criticals, so a function that crosses a limit gets split. - an unused require, private function or public function that the allowlist doesn't name with a:why, and an allowlist entry that's no longer reported, so the list stays true.
The gate also refuses to run against an allowlist with no :critical-free paths, or a report missing its sections, rather than pass having checked nothing. dev/ (the fake servers, demo shots and the self-test harness) isn't in the gate yet. Bringing it in takes two edits: add "dev/" to :critical-free, and take dev out of .jolt-metrics.edn's :test-paths. The metrics job in .github/workflows/lint.yml runs it on every push to main and every pull request. - jolt lsp:format / jolt lsp:format-check and jolt lsp:clean-ns / jolt lsp:clean-ns-check reformat (or dry-run) code and ns forms with clojure-lsp; jolt lsp:check runs both dry-run checks. - The LSP tasks cover src, test and dev, skipping dev/smoke/fixtures/*.clj (deliberately invalid Clojure that aborts clojure-lsp's parser), dev/smoke/PixelAt.java, and the two files lifted from DrClojure-Neo (src/jonquil/syntax.clj, test/jonquil/upstream/syntax_test.clj) so re-syncs diff cleanly. - jolt hooks:install writes a fast pre-commit hook (clj-kondo errors plus a format dry-run). jolt hooks:install:full adds the full test suite. jolt hooks:uninstall removes either. Installing runs the checks first and warns if the tree doesn't pass yet.
The lint workflow
.github/workflows/lint.yml runs jolt lint:strict and jolt lsp:format-check on ubuntu-latest, with Jolt installed to run the tasks, plus the metrics job (Jolt 0.8.27). It runs on every push to main and every pull request, and by hand from the Actions tab or with gh workflow run lint.yml.
Benchmark
jolt bench:edit [sizes...] times what typing costs in a large file. For each size (default 200, 2000, 8000 and 20000 lines of Jonquil's own source) it opens a real GTK window, so leave the keyboard alone. It prints bench <measure> lines=<n> ms=<x> lines:
plan: the medianpaint-planrun (background thread, so not a stall).stall-edit,stall-quote: the longest main-loop iteration in the 2 s after typing a letter, or a double quote (which flips string parity), into the firstdefnname in the first 40 lines.settle-stall-edit,settle-stall-quote: time until the repaint has landed;VOIDif it didn't settle.jump: the median jump-to-definition.
Each timed run gets a fresh copy of the text, because the tokenizer caches by string identity. What remains of the stall at 20,000 lines is the copy of the buffer text on the GTK thread, about 50 ms.
Upstream
jolt upstream:diff lists upstream DrClojure-Neo commits since UPSTREAM_PIN that touch src or test. It needs no setup: it keeps its own clone under ~/.cache/jonquil/. See Porting and attribution.
Docs
jolt site:build, jolt site:serve [port] and jolt site:clean build and preview this documentation site (docs/guide/) using a sibling checkout of jlt-commons/docs-engine, looked up as $JLT_DOCS_ENGINE, then ~/dev/jlt-commons/docs-engine, then ../docs-engine (a sibling there can be an unrelated older fork). If none exists, the task prints where to git clone it. site:serve also needs babashka (bb) on PATH. The engine's preview server is http-kit, which Jolt doesn't bundle, so that one task runs the engine's bb serve. site:build and site:clean run under Jolt. There's no workflow or GitHub Pages site behind this: it's a local preview until the project is open-sourced.
This page is docs/guide/development.md in the Jonquil repository.