Clojure language handlers for Vis.
Format/test/REPL are exposed through the generic language facade
(format, test, repl_eval, repl_start, repl_stop) —
format here does parinfer delimiter repair + cljfmt, and the same repair is
registered as the pack's :balance-fn: the foundation's editors call it with
the WHOLE spliced file when an edit would not parse, and write the repair only
when it stays inside the lines that edit wrote. Both doors are ADD-ONLY: a delimiter
you omitted is added back, one you WROTE is never deleted — a lost opening ( and
one ) too many are the same string, so deleting is a guess that rewrites code, and
the text an edit REPLACED is the only thing that tells the two apart.
Clojure language handlers for Vis. Format/test/REPL are exposed through the generic language facade (`format`, `test`, `repl_eval`, `repl_start`, `repl_stop`) — `format` here does parinfer delimiter repair + cljfmt, and the same repair is registered as the pack's `:balance-fn`: the foundation's editors call it with the WHOLE spliced file when an edit would not parse, and write the repair only when it stays inside the lines that edit wrote. Both doors are ADD-ONLY: a delimiter you omitted is added back, one you WROTE is never deleted — a lost opening `(` and one `)` too many are the same string, so deleting is a guess that rewrites code, and the text an edit REPLACED is the only thing that tells the two apart.
Config-driven Clojure source formatter used by clj/edit for format-on-write
and by the format_code language-surface verb.
TWO backends live here, and the choice is TRANSPARENT to the language surface — callers just format; this namespace picks the formatter from the config files present around the target path:
.zprint.edn/.zprintrc is found walking UP from the
path. The project's zprint options map is applied. This is the
canonical, reflowing formatter — for this repo it is THE
formatter, applied through the repo's own .zprint.edn..cljfmt.edn/.cljfmt.clj is found (no zprint), or
when neither config exists (cljfmt defaults). Conservative:
normalizes indentation + whitespace of MULTI-LINE forms but does
NOT reflow a one-liner into multiple lines.When BOTH configs are present, zprint WINS. Backends and their config loaders are resolved on first use, not while registering the language pack at gateway startup. Registering formatting handlers does not need either implementation.
Failure mode: if a backend refuses (parse error, unfamiliar reader macro, anything that throws), the formatter returns the original source unchanged. We never silently corrupt a file because the formatter choked.
Config-driven Clojure source formatter used by `clj/edit` for format-on-write
and by the `format_code` language-surface verb.
TWO backends live here, and the choice is TRANSPARENT to the language
surface — callers just format; this namespace picks the formatter from the
config files present around the target path:
* zprint — when a `.zprint.edn`/`.zprintrc` is found walking UP from the
path. The project's zprint options map is applied. This is the
canonical, reflowing formatter — for this repo it is THE
formatter, applied through the repo's own `.zprint.edn`.
* cljfmt — when only a `.cljfmt.edn`/`.cljfmt.clj` is found (no zprint), or
when neither config exists (cljfmt defaults). Conservative:
normalizes indentation + whitespace of MULTI-LINE forms but does
NOT reflow a one-liner into multiple lines.
When BOTH configs are present, zprint WINS. Backends and their config loaders
are resolved on first use, not while registering the language pack at gateway
startup. Registering formatting handlers does not need either implementation.
Failure mode: if a backend refuses (parse error, unfamiliar reader macro,
anything that throws), the formatter returns the original source unchanged.
We never silently corrupt a file because the formatter choked.clj-kondo linting for the Vis language surface.
Runs clj-kondo's programmatic API (clj-kondo.core/run!) — never shells out —
over a code string (fed on stdin as -), explicit path(s), or the workspace's
default source paths, and returns a uniform result map (STRING keys — crosses
the strings-only boundary as a tool :result):
{"op" "clj-lint" "error" N "warning" N "info" N "files" N "findings" [...]}
where each finding is {"file" "row" "col" "level" "type" "message" "provider"}
(every finding names clj-kondo as its provider).
Resolve the analyzer on the first lint request, not when a language pack is registered: non-Clojure sessions do not need its compiler and analysis tables.
clj-kondo linting for the Vis language surface.
Runs clj-kondo's programmatic API (`clj-kondo.core/run!`) — never shells out —
over a code string (fed on stdin as `-`), explicit path(s), or the workspace's
default source paths, and returns a uniform result map (STRING keys — crosses
the strings-only boundary as a tool `:result`):
`{"op" "clj-lint" "error" N "warning" N "info" N "files" N "findings" [...]}`
where each finding is `{"file" "row" "col" "level" "type" "message" "provider"}`
(every finding names clj-kondo as its provider).
Resolve the analyzer on the first lint request, not when a language pack is
registered: non-Clojure sessions do not need its compiler and analysis tables.Thin, observable nREPL client for clj/eval.
Connection model:
nrepl.core/connect socket per [host port] key, cached
on a defonce atom so we survive (require :reload) during
development.eval!. This is how Cider/Calva/every editor drives nREPL: no
per-eval clone/close round-trip on the hot path (those were
what blew the run_tests budget under JVM load), nothing
to leak, and session-local state — *1/*2/*3/*e and dynamic
set!s — PERSISTS across calls like a real REPL ((def …) was
already global; now the whole session is).IOException / nil
message stream) and the entry is evicted — closing the socket and
dropping the cached session — so the next call re-dials + re-clones.Returned shape (success) — STRING keys (crosses the strings-only boundary
as a tool :result; enrichment adds "error_message"/"error_data"/"trace"):
{"value" "42" ; pr-str of the LAST form's value, or nil
"values" ["1" "42"] ; pr-str of every emitted value
"out" "hello\n" ; stdout aggregated
"err" "" ; stderr aggregated
"ns" "user" ; final ns name
"status" #{"done"} ; nREPL status set (strings)
"ex" nil ; exception class name, when status :ex
"root_ex" nil ; root exception class name
"ms" 12 ; wall-clock duration
"port" 7888
"timed_out" false}
Failure paths throw ex-info with :type :clj/nrepl-* so the
Vis tool wrapper can surface a clean error to the model.
Thin, observable nREPL client for `clj/eval`.
Connection model:
* One `nrepl.core/connect` socket per `[host port]` key, cached
on a `defonce` atom so we survive `(require :reload)` during
development.
* ONE long-lived nREPL session per connection, cloned lazily on
first use and cached beside the socket — then REUSED by every
`eval!`. This is how Cider/Calva/every editor drives nREPL: no
per-eval `clone`/`close` round-trip on the hot path (those were
what blew the `run_tests` budget under JVM load), nothing
to leak, and session-local state — `*1`/`*2`/`*3`/`*e` and dynamic
`set!`s — PERSISTS across calls like a real REPL (`(def …)` was
already global; now the whole session is).
* Stale / closed sockets are detected (`IOException` / `nil`
message stream) and the entry is evicted — closing the socket and
dropping the cached session — so the next call re-dials + re-clones.
Returned shape (success) — STRING keys (crosses the strings-only boundary
as a tool `:result`; enrichment adds "error_message"/"error_data"/"trace"):
{"value" "42" ; pr-str of the LAST form's value, or nil
"values" ["1" "42"] ; pr-str of every emitted value
"out" "hello\n" ; stdout aggregated
"err" "" ; stderr aggregated
"ns" "user" ; final *ns* name
"status" #{"done"} ; nREPL status set (strings)
"ex" nil ; exception class name, when status :ex
"root_ex" nil ; root exception class name
"ms" 12 ; wall-clock duration
"port" 7888
"timed_out" false}
Failure paths throw `ex-info` with `:type :clj/nrepl-*` so the
Vis tool wrapper can surface a clean error to the model.Per-turn nREPL resource synchronization for the Clojure pack.
Live state has ONE model-facing home: repl_status. This extension hook probes
owned nREPLs and mirrors them into the generic session resource registry —
what repl_status and the footer answer from; nothing about a resource rides in
ctx. It returns no legacy session["env"]["languages"] contribution.
OWNERSHIP: we surface ONLY the REPLs THIS session started + owns, PLUS any
external nREPL the user EXPLICITLY attached via connect (both from
repl-manager/session-repls). There is still NO external-port discovery and
no .nrepl-port scanning — attachment is explicit consent, never a scan.
Eval defaults to the workspace-root REPL (else the first) when several exist; the result reports which REPL ran. Each mirror carries liveness status and diagnostics from a per-turn probe.
All best-effort: any failure degrades to an empty contribution and never blocks the render.
Per-turn nREPL resource synchronization for the Clojure pack. Live state has ONE model-facing home: `repl_status`. This extension hook probes owned nREPLs and mirrors them into the generic session resource registry — what `repl_status` and the footer answer from; nothing about a resource rides in ctx. It returns no legacy `session["env"]["languages"]` contribution. OWNERSHIP: we surface ONLY the REPLs THIS session started + owns, PLUS any external nREPL the user EXPLICITLY attached via `connect` (both from `repl-manager/session-repls`). There is still NO external-port discovery and no `.nrepl-port` scanning — attachment is explicit consent, never a scan. Eval defaults to the workspace-root REPL (else the first) when several exist; the result reports which REPL ran. Each mirror carries liveness status and diagnostics from a per-turn probe. All best-effort: any failure degrades to an empty contribution and never blocks the render.
Delimiter repair for Clojure source the model hand-wrote.
Ported from bhauman/clojure-mcp-light (clojure-mcp-light.delimiter-repair,
Apache-2.0): repair via parinfer indent-mode, which trusts the INDENTATION to
place the missing / extra ( [ { and so matches how the model intended the
code to nest. The parinfer-rust shell path + stats/json bits from upstream are
dropped; this is the pure JVM path only, over com.blockether/parinferish —
Blockether's linear-time rewrite of parinferish 0.8.0.
Two readers, two questions, and neither answers the other's. parinferish says
whether the DELIMITERS balance, so the gate is the same reader that performs
the repair and a whole file costs one linear scan. edamame says whether text
READS as Clojure, which balanced delimiters do not promise: source cut
mid-token comes back closed as (:) — balanced, and not a keyword.
fix-delimiters is the entry point, and it repairs WHOLE Clojure source: format
runs it before cljfmt, and the pack publishes it as the editors' :balance-fn, which
the foundation applies to the whole file an edit would write and keeps only when the
repair stays on that edit's own lines. Handing it a partial form instead balances the
fragment into a complete one that means something else.
Delimiter repair for Clojure source the model hand-wrote.
Ported from bhauman/clojure-mcp-light (`clojure-mcp-light.delimiter-repair`,
Apache-2.0): repair via parinfer indent-mode, which trusts the INDENTATION to
place the missing / extra `( [ {` and so matches how the model intended the
code to nest. The parinfer-rust shell path + stats/json bits from upstream are
dropped; this is the pure JVM path only, over `com.blockether/parinferish` —
Blockether's linear-time rewrite of parinferish 0.8.0.
Two readers, two questions, and neither answers the other's. parinferish says
whether the DELIMITERS balance, so the gate is the same reader that performs
the repair and a whole file costs one linear scan. edamame says whether text
READS as Clojure, which balanced delimiters do not promise: source cut
mid-token comes back closed as `(:)` — balanced, and not a keyword.
`fix-delimiters` is the entry point, and it repairs WHOLE Clojure source: `format`
runs it before cljfmt, and the pack publishes it as the editors' `:balance-fn`, which
the foundation applies to the whole file an edit would write and keeps only when the
repair stays on that edit's own lines. Handing it a partial form instead balances the
fragment into a complete one that means something else.The :general lint provider: Clojure COMPILER warnings — reflection and
boxed math.
Unlike clj-kondo (static analysis over source text), these warnings only
exist at COMPILE time: the compiler emits them while it resolves interop /
code. So this provider COMPILES whatever the lint TARGETS — a lint_code code
string, or each source file being linted — in a throwaway namespace that is
torn down afterwards, so the running system is never mutated and nothing leaks.
It compiles the code in a throwaway namespace with *warn-on-reflection* and
*unchecked-math* :warn-on-boxed bound, captures the compiler's *err*
stream, and parses each warning line
Reflection warning, <file>:<row>:<col> - <message>
Boxed math warning, <file>:<row>:<col> - <message>
into the uniform lint finding map, tagged "provider" "general":
{"file" "row" "col" "level" "warning" "type" "reflection"|"boxed-math" "message" "provider" "general"}.
The `:general` lint provider: Clojure COMPILER warnings — reflection and
boxed math.
Unlike clj-kondo (static analysis over source text), these warnings only
exist at COMPILE time: the compiler emits them while it resolves interop /
code. So this provider COMPILES whatever the lint TARGETS — a `lint_code` code
string, or each source file being linted — in a throwaway namespace that is
torn down afterwards, so the running system is never mutated and nothing leaks.
It compiles the code in a throwaway namespace with `*warn-on-reflection*` and
`*unchecked-math* :warn-on-boxed` bound, captures the compiler's `*err*`
stream, and parses each warning line
`Reflection warning, <file>:<row>:<col> - <message>`
`Boxed math warning, <file>:<row>:<col> - <message>`
into the uniform lint finding map, tagged `"provider" "general"`:
`{"file" "row" "col" "level" "warning" "type" "reflection"|"boxed-math"
"message" "provider" "general"}`.Owned, session-scoped nREPL lifecycle for the Clojure pack.
OWNERSHIP: each vis SESSION owns its own nREPL subprocess(es). The processes
atom is keyed by [session-id dir], so two sessions in the same directory get
two independent REPLs and neither can see or stop the other's. A managed REPL
lives and dies with THIS vis process — there is NO persistent registry and NO
PID re-attach across a vis restart. Restarting vis means a fresh REPL, exactly
like the Python pack.
PORT: we PICK a free ephemeral port ourselves and pass it to the launcher
EXPLICITLY (nrepl.cmdline --port N, lein repl :headless :port N,
bb nrepl-server N), so we always KNOW our port without ever reading a
.nrepl-port file back. Any stray .nrepl-port a tool drops in the project is
deleted after boot — vis never depends on it and never leaves it behind.
ALIASES: a REPL is ALWAYS booted with the project's :dev :test deps + paths
on its classpath (full dependency spec), with the user's :main-opts dropped
(our synthetic :vis/nrepl-launch alias appends last so -m nrepl.cmdline
wins). Unknown :dev/:test aliases are silently ignored by tools.deps, so
this is safe in any project.
ATTACHMENTS: connect! registers an EXTERNAL nREPL the user already runs in a
SEPARATE attachments atom, never in processes. They are different kinds:
one is a process we own and must eventually kill, the other is an address we
were invited to use. Keeping them apart is what lets ONE project have both at
once — the managed JVM REPL repl start booted for its .clj, and the
shadow-cljs nREPL its own watch runs for the .cljs — instead of the second
connect answering "already-running" about the first and handing back a JVM
REPL nobody asked for.
Starting/stopping is CORE and ALWAYS allowed — never gated behind a flag.
Owned, session-scoped nREPL lifecycle for the Clojure pack. OWNERSHIP: each vis SESSION owns its own nREPL subprocess(es). The `processes` atom is keyed by `[session-id dir]`, so two sessions in the same directory get two independent REPLs and neither can see or stop the other's. A managed REPL lives and dies with THIS vis process — there is NO persistent registry and NO PID re-attach across a vis restart. Restarting vis means a fresh REPL, exactly like the Python pack. PORT: we PICK a free ephemeral port ourselves and pass it to the launcher EXPLICITLY (`nrepl.cmdline --port N`, `lein repl :headless :port N`, `bb nrepl-server N`), so we always KNOW our port without ever reading a `.nrepl-port` file back. Any stray `.nrepl-port` a tool drops in the project is deleted after boot — vis never depends on it and never leaves it behind. ALIASES: a REPL is ALWAYS booted with the project's `:dev :test` deps + paths on its classpath (full dependency spec), with the user's `:main-opts` dropped (our synthetic `:vis/nrepl-launch` alias appends last so `-m nrepl.cmdline` wins). Unknown `:dev`/`:test` aliases are silently ignored by tools.deps, so this is safe in any project. ATTACHMENTS: `connect!` registers an EXTERNAL nREPL the user already runs in a SEPARATE `attachments` atom, never in `processes`. They are different kinds: one is a process we own and must eventually kill, the other is an address we were invited to use. Keeping them apart is what lets ONE project have both at once — the managed JVM REPL `repl start` booted for its `.clj`, and the shadow-cljs nREPL its own `watch` runs for the `.cljs` — instead of the second `connect` answering "already-running" about the first and handing back a JVM REPL nobody asked for. Starting/stopping is CORE and ALWAYS allowed — never gated behind a flag.
shadow-cljs as the ClojureScript TEST RUNNER: which build runs the tests, how
THIS machine invokes shadow-cljs, and the exact argv that runs a narrowed
selection. A *_test.cljs never loads on the JVM, so clojure -M:test can no
more run it than node can run a .clj — the build is not a preference here,
it is the only runtime that exists.
Three facts decide the command, and each has its own honest refusal instead of a guess:
node_modules/.bin/shadow-cljs (npm) wins
because it is what the project's own npm test runs; a
thheller/shadow-cljs dependency in deps.edn runs as
clojure -M[:alias] -m shadow.cljs.devtools.cli — the SAME project may
carry it either way, and an alias-only dependency needs that alias on the
command line or the classpath lacks the namespace being -m'd. Declared in
package.json but not installed is answered as npm install, not as
"no ClojureScript runner".--config-merge carries namespace focus and
disables Node autorun. Compile first, then run Node separately: shadow's
autorun does not propagate the child exit status. Print overrides with
pr-str; hand-built regexp escapes can make shadow print help and exit
ZERO without compiling anything.shadow-cljs as the ClojureScript TEST RUNNER: which build runs the tests, how THIS machine invokes shadow-cljs, and the exact argv that runs a narrowed selection. A `*_test.cljs` never loads on the JVM, so `clojure -M:test` can no more run it than `node` can run a `.clj` — the build is not a preference here, it is the only runtime that exists. Three facts decide the command, and each has its own honest refusal instead of a guess: 1. HOW shadow-cljs is installed. `node_modules/.bin/shadow-cljs` (npm) wins because it is what the project's own `npm test` runs; a `thheller/shadow-cljs` dependency in `deps.edn` runs as `clojure -M[:alias] -m shadow.cljs.devtools.cli` — the SAME project may carry it either way, and an alias-only dependency needs that alias on the command line or the classpath lacks the namespace being `-m`'d. Declared in `package.json` but not installed is answered as `npm install`, not as "no ClojureScript runner". 2. WHICH build runs tests. Resolve an explicit build or the sole test build; never guess between suites. :node-test runs headless, :karma drives its own browser, and :browser-test needs a browser RUNTIME that this runner cannot supply. Compiling alone is never evidence of passing tests. 3. WHAT the run is narrowed to. `--config-merge` carries namespace focus and disables Node autorun. Compile first, then run Node separately: shadow's autorun does not propagate the child exit status. Print overrides with `pr-str`; hand-built regexp escapes can make shadow print help and exit ZERO without compiling anything.
shadow-cljs as the ClojureScript REPL RUNTIME: attaching to the nREPL a
shadow-cljs watch already runs, SELECTING the build whose JS runtime an eval
must land in, and keeping that selection true for every later eval.
Five facts about that server decide everything here, and every one of them was learned from a live shadow-cljs, not from its wire protocol:
describe: it advertises no cljs/shadow op and reports only a :clojure
version, so dialect detection from describe metadata answers :clj for it.
The only honest question is a read-only eval that RESOLVES
shadow.cljs.devtools.api inside the server.(shadow…api/nrepl-select :app) —
and ONE session serves EVERY build of that server, so a selection belongs
to the CONNECTION and never to an attachment: selecting :worker makes
the next eval that believes it targets :app answer from the worker's
runtime, silently and with no error. Every eval therefore re-checks both
the session and the build selected in it, never a boolean.No such namespace: shadow.cljs.devtools.api. :cljs/quit first is what makes selection
total: it returns such a session to CLJ, and in a session that never left
CLJ it is an ordinary keyword evaluating to itself.nrepl-select answers the SAME watch for build not running whether the
build id does not exist or is merely not being watched. The two are told
apart BEFORE selecting — by the server's own build config and
worker-running? — because they need opposite fixes.No available JS runtime. on :err with a done
status. Nothing but a started runtime fixes it, so that answer is turned
into the instruction that starts one.shadow-cljs as the ClojureScript REPL RUNTIME: attaching to the nREPL a `shadow-cljs watch` already runs, SELECTING the build whose JS runtime an eval must land in, and keeping that selection true for every later eval. Five facts about that server decide everything here, and every one of them was learned from a live shadow-cljs, not from its wire protocol: 1. A shadow-cljs nREPL is INDISTINGUISHABLE from a plain JVM one over `describe`: it advertises no cljs/shadow op and reports only a `:clojure` version, so dialect detection from describe metadata answers `:clj` for it. The only honest question is a read-only eval that RESOLVES `shadow.cljs.devtools.api` inside the server. 2. The build is selected PER nREPL SESSION — `(shadow…api/nrepl-select :app)` — and ONE session serves EVERY build of that server, so a selection belongs to the CONNECTION and never to an attachment: selecting `:worker` makes the next eval that believes it targets `:app` answer from the worker's runtime, silently and with no error. Every eval therefore re-checks both the session and the build selected in it, never a boolean. 3. Selecting from a session ALREADY sitting in a build FAILS — the select form is compiled as ClojureScript and dies on `No such namespace: shadow.cljs.devtools.api`. `:cljs/quit` first is what makes selection total: it returns such a session to CLJ, and in a session that never left CLJ it is an ordinary keyword evaluating to itself. 4. `nrepl-select` answers the SAME `watch for build not running` whether the build id does not exist or is merely not being watched. The two are told apart BEFORE selecting — by the server's own build config and `worker-running?` — because they need opposite fixes. 5. A selected build with NO connected JS runtime neither errors nor hangs: every eval answers `No available JS runtime.` on `:err` with a `done` status. Nothing but a started runtime fixes it, so that answer is turned into the instruction that starts one.
Run a namespace's tests in the session's ALREADY-RUNNING nREPL (the fast inner loop) or -- the default, and whenever there is no such REPL -- by shelling the project's own test command in a clean JVM. Nothing here EVER starts a REPL.
The in-REPL path is FRAMEWORK-AGNOSTIC: a ns whose vars carry clojure.test :test metadata runs through clojure.test/run-tests; otherwise it is treated as lazytest and run through lazytest.runner/run-tests. Either way the result is a uniform STRING-keyed map (crosses the strings-only boundary) with "mode" (repl or cli), "framework", "ns", "total", "pass", "fail" and "failures" [{"ns" "test" "message" "file" "line"} ...]. CLI and shadow-cljs retain complete stdout/stderr in "output" (ANSI-stripped) and collect per-fault diagnostics before rendering. Only the display preview may be bounded; the result data is never reduced to a log tail.
run-form is the code EVALED on the target nREPL. It is a quoted form (not a call into this namespace) so it works against ANY project's nREPL, including hosts that do not have the vis-agent extension on their classpath.
Run a namespace's tests in the session's ALREADY-RUNNING nREPL (the fast inner
loop) or -- the default, and whenever there is no such REPL -- by shelling the
project's own test command in a clean JVM. Nothing here EVER starts a REPL.
The in-REPL path is FRAMEWORK-AGNOSTIC: a ns whose vars carry clojure.test
:test metadata runs through clojure.test/run-tests; otherwise it is treated
as lazytest and run through lazytest.runner/run-tests. Either way the result
is a uniform STRING-keyed map (crosses the strings-only boundary) with
"mode" (repl or cli), "framework", "ns", "total", "pass", "fail" and
"failures" [{"ns" "test" "message" "file" "line"} ...].
CLI and shadow-cljs retain complete stdout/stderr in "output" (ANSI-stripped)
and collect per-fault diagnostics before rendering. Only the display preview
may be bounded; the result data is never reduced to a log tail.
run-form is the code EVALED on the target nREPL. It is a quoted form (not a
call into this namespace) so it works against ANY project's nREPL, including
hosts that do not have the vis-agent extension on their classpath.cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |