This repo is mostly bot-written.
Form-addressed Clojure editing. cljform is a Rust CLI (plus a pi
extension) that lets an agent — or you — edit Clojure/EDN files at the
s-expression level: forms are addressed by content-hashed handles, every
write is verified against the parse tree, and there is no way to leave a
file unparsable.
It exists because text-oriented edit tools fail on s-expressions in a specific way: a misplaced paren still parses (as the wrong structure), and a "balanced" edit can silently swallow the forms after it. cljform makes those failures either impossible or loud.
The way I prefer to work is with LLM assistance, queueing up a batch of tasks with a smarter planner model, then walking away and thinking about something else, and reviewing the output. I use local models (Qwen 3.8 27b as of this writing) for the bulk of my code generation. They have gotten good enough that line edits mostly work, but clojure is consistently a painful experience. I might walk away and come back to bespoke python scripts being made to count unbalanced parens and just endless looping and context bloat from failed edits. Parinfer-rust got me closer to a solution, but this is a more purpose-built tool for agents' expectations, built by constant dogfooding between GLM 5.3 flash and my local model, with me nudging the design. The original approach came from this article: https://lispmeister.github.io/deeprecursion/posts/2026-02-13-sexp-native-editing.html . I started with tree-sitter and the LLM's own retrospective of a session with a lot of failed edits.
I care less about conceptual cleanliness in this project than I do about having a useful tool with better working tradeoffs, and the implementation details are unstable.
$ cljform tree todos.clj
(⟦a68821⟧ns demo.todos)
(⟦bd7b68⟧def todos (atom []))
(⟦043321⟧defn add-todo [text]
(swap! todos conj {:text text :done false}))
(⟦a2fbf8⟧defn complete-all []
(swap! todos (fn [t] (map #(assoc % :done true) t))))Notice which forms got handles: every top-level form, plus nested forms
large enough to span multiple lines (here, none). That is the default
view's readability heuristic — a Clojure file is dense with small
collections ((assoc …), [x y], {:k v}), and marking each one would
bury the source in ⟦handle⟧ glyphs. The line the heuristic draws is also
the workflow line: a multi-line nested form is worth editing as a
whole unit, so it gets a handle; a single-line inner expression is a
patch target instead — you change it through its parent's handle, which
needs no deep handle at all. When a nested form is multi-line, it gets
its own handle — and its multi-line children get theirs, down the tree:
$ cljform tree report.clj
(⟦da51a6⟧defn render-report [rows]
(⟦71a7de⟧let [⟦a85514⟧total (reduce + 0 (map :amount rows))
lines (⟦f3a214⟧for [r rows]
(format "%-20s %8.2f\n" (:name r) (:amount r)))]
(⟦83c8ea⟧str "TOTAL: " total "\n"
(apply str lines))))The let, the for, and the str span lines, so they carry handles;
their single-line children ((reduce …), (format …), the binding
vectors) do not. When you want every collection regardless:
--full marks all, --name SYM renders a matched form's entire subtree,
and --json always lists a handle for every node at every depth
(--depth N filters that node table to depth ≤ N).
In the pi extension, a large file (> 300 lines) returns the form index
instead of the source on a default clj_tree call: one
⟦handle⟧ head name (lines X–Y) row per top-level form, every handle
live, no source body — the index scales with form count, the annotated
view with file size. Page a region with startLine/endLine (or select
with name) and the annotated view comes back exactly as the CLI's.
Every handle is a short content hash, and the only edit target. Edit by handle:
$ cljform edit todos.clj --handle a2fbf8 --mode patch \
--old-text '(map #(assoc % :done true) t)' \
--new-text '(map (fn [todo] (assoc todo :done true :completed-at (now))) todo)'
verified — 1 changed, 3 untouched · warnings: 0 new (0 pre-existing)
--- before / +++ after …
patched form ⟦b36464⟧ complete-all (lines 8–9) — 1 changed, 3 untouched
4 defn complete-all lines 8–9 ⟦b36464⟧
4 forms; 1 changed, 3 untouched — tree todos.clj for the full tableThe response leads with the verdict line: verified — C changed, U untouched · warnings: 0 new (P pre-existing) — the edit is verified
(parse, untouched-forms byte-identity, and the detector walk run on the
PRE-edit parse matched against the post-edit one), and every warning is
attributed: did I cause this, or was it always here? When the edit
introduces a warning, the line becomes N new warning(s) (P pre-existing):
and the new entries follow it, so a clean edit can be told from a
warning-introducing one at a glance (SPEC §10.3, issue 38).
The response is result-first: the changed-region diff, the summary
line, the affected form's row (with its new handle — your next edit
target), and the counts line. The whole-file table is never dumped here
— tree and forms are its home, and the counts line points you there.
Mistype the target text and the refusal hands you the form's exact bytes to copy from — recovery is one retry, not a re-read:
$ cljform edit todos.clj --handle 043321 --mode patch --old-text '…wrong…'
error [patch-not-found]: --old-text not found inside list_lit (lines 5–6) …
exact form bytes (copy oldText from these): …An unchanged form keeps its handle across edits elsewhere; a changed one rotates its handle and the edit summary says so — a stale handle can re-aim, never mis-aim.
A multi-form change is ONE batch — N ops in one atomic call, all handles
from a single tree run:
$ cljform edit todos.clj --batch ops.json
# ops.json: [{"handle": "b36464", "mode": "patch", "oldText": "...", "newText": "..."},
# {"handle": "a2fbf8", "mode": "replace", "content": "(defn ...)"}]
op 0: patched form ⟦…⟧ complete-all (lines 8–9) …
op 1: replaced form ⟦…⟧ complete-todos (lines 1–7) …
2 ops applied; file: 4 forms; 2 changed, 2 untouchedEvery op's handle resolves against the original file; the engine
tracks each target through the batch's own ops, so docstring-then-body on
one form is two ops sharing one handle — no stale-handle churn. A later
op on a form an earlier op removed or replaced fails the whole batch
(cleanly, target-removed, naming the op that removed it) and writes
nothing; every op is verified with the full single-op pipeline on the
evolving file. A batch cannot target forms it creates (create-then-edit
stays separate calls). Full contract: SPEC §10.3 (batch).
- Handles are content-addressed.
treemarks each form with⟦handle⟧; that handle is the only addresseditaccepts. Patch mode (issue 46) can also target a top-level def-like by--name— safe there because the patch's oldText must match current bytes, so the payload is its own freshness guard. The decision rule: new multi-line form →clj_draft(vote included) · small in-form change →clj_edit patch(by handle, or--name) · whole-form swap →clj_edit replace(handle). Resolution either matches the one form it names or refuses (stale-handle,ambiguous-handle) — a stale view can never target the wrong form. - Writes are verified. A write happens only if the post-splice file parses clean and every untouched form is byte-identical to before. Otherwise nothing is written and the error carries the exact line/col.
- Inference is opt-in. Unbalanced content is refused by default with
the inferred candidate attached;
--repairapplies the inference from indentation (reported, never silent). Submitted content is reindented in parinfer paren mode and base-shifted to the target's column by default. - Drift is reported, not hidden. A balanced-but-wrong structure (the
original motivating bug: a drifted closer swallowing three
deftests) parses fine, so detectors D1/D2 flag it with exact line ranges;--strictturns warnings into refusals for CI.
| Op | Purpose |
|---|---|
tree <file> |
The annotated view — source with ⟦handle⟧ per form. The primary discovery surface. --name SYM selects a nested form by name; --start-line/--end-line page large files (complete forms only); --json emits the node table (--depth N filters it to depth ≤ N); --recover is the broken-file view (below) |
forms <file> |
Flat table: addr, kind, name, lines, blake3, shape summary, warnings |
get <file> --name SYM | --handle H |
One form's exact bytes + metadata (pass its handle to edit); human view is payload-first: the bytes, then one metadata line |
edit <file> --handle H |
Whole-form edit: --mode replace|patch|insert-after|insert-before|append|prepend|delete, with --content/--content-file (or stdin) or patch --old-text/--new-text (+ --old-text-file/--new-text-file — file routes are mutually exclusive with the inline twins, bytes verbatim, exit 4 io on a missing/invalid-UTF-8 file; the envelope echoes contentSource/oldTextSource/newTextSource). Returns a unified diff of the changed region. See below for repair/strict |
edit <file> --batch OPS.json |
N edit ops in one atomic call: a JSON array of {handle, mode, content / oldText+newText}. Every handle resolves against the original file and each target is tracked across the batch; per-op blocks + aggregate counts; any failure writes nothing |
check <file> |
Parse + form table + detector warnings (file or stdin) |
materialize --content C |
Indent-mode bracket completion → candidate + diff, never writes |
balance [file] |
Bracket-balance a fragment (read-only, stdin-first, --tail T tests a candidate tail): missing closers → the exact mechanical tail (strings/comments/charlits/regex don't count); a misplaced closer → a line:col diagnosis with the full open stack, no tail offered. JSON: balanced is the verdict (ok only says the command ran), counts is the complete census, matched the pairs that matched (omitted on a mismatch — a partial count would look like a total), and stack always ends with the innermost opener the message names. Also the standalone blob validator — cljform balance --stdin checks any fragment without a target file or handle. Exit 0 balanced / 1 unbalanced or mismatch |
format <file> |
Reindent → candidate + diff, never writes. Two regimes: parinfer paren mode (default) and a native cljfmt 0.16.6 port (--fmt cljfmt, or CLJFORM_FMT=cljfmt) |
strip |
Remove ⟦…⟧ markers → exact original bytes (pure filter, file or stdin) |
--json works on every envelope op; --human (or a TTY) for the readable
view. --dry-run validates and writes nothing. Full contract: SPEC §4.
Exit codes: 0 ok · 1 parse failure / broken-file gate / internal-error
· 2 usage · 3 targeting or refused content · 4 I/O. Nothing is ever
written on a nonzero exit.
Merge conflicts are where agents are most tempted to fall back to raw text edits — and where a subtlety bites: conflict markers are legal Clojure symbols, so a conflicted file still parses, and a naive tool will happily edit one side and call it fixed.
cljform treats a conflicted (or otherwise unparsable) file as a first-class broken state:
$ cljform check conf.clj
error [conflict-markers]: 1 conflict region(s) (lines 3-7)
hint: resolve the conflict(s) with a text edit; cljform resumes when the file parsesEvery op refuses a broken file — an edit cannot land on one side of a
conflict and call it resolved. tree --recover shows the way out: verbatim
source, each conflict region with per-side line spans (HEAD / base /
incoming), parse-error spans, and the intact forms around them — no handles
yet, because none are usable while the file is broken:
$ cljform tree conf.clj --recover
file does not parse — 1 conflict region(s), 0 parse error(s);
handles appear when the file is repaired
source (lines 1–7): …
conflict region lines 3–7 (head 3–4 · incoming 5–7)Resolve with a text edit, check goes green, and the normal handle flow
resumes. The pi extension's guard hook reports the same diagnostics after
every built-in edit, with the remaining region count decreasing as you work
through a multi-conflict file.
| ID | Fires on |
|---|---|
| D1 | deftest nested inside executable scope (defn, let, fn, threading macros, …) |
| D2 | any def… head nested inside another def's body (accidental local def) |
| D3 | ns not the first form |
Quotes, syntax quotes, regex/char literals, (comment …) bodies and #_
discards are inert; reader conditionals are transparent. Detection runs on a
64 MB-stack worker thread — 50k-deep data is fine.
extension/clojure-forms.ts registers five tools — clj_tree,
clj_get, clj_edit, clj_forms, clj_draft — that wrap the CLI with
agent-shaped ergonomics: discovery, exact-byte fetch, handle-targeted
editing (a four-line change in a 60-line form no longer means
re-transcribing 60 lines), and the guard hook on built-in edits.
clj_tree on a large file (> 300 lines) returns the form index by
default — one live-handle row per top-level form, no source — and pages
with startLine/endLine. See SPEC §8 for the full tool contract.
Install (the symlinks make the extension live-track this repo):
cargo install --path .
ln -s "$PWD/extension/clojure-forms.ts" ~/.pi/agent/extensions/clojure-forms.ts
ln -s "$PWD/extension/agents/clojure-worker.md" ~/.pi/agent/agents/clojure-worker.mdThe format op has two regimes, each a native port (no shared code, no
runtime dependency on the reference tool):
- parinfer regime (default): reimplements parinfer's paren-mode
indentation semantics; the behavioral reference was
parinfer-rust — Jason
Felice's Rust implementation (ISC), carrying Shaun Lebron's original
parinfer design (pinned checkout: commit
1a0647d). If you want battle-tested parinfer as a library rather than a form-editing tool, use parinfer-rust. - cljfmt regime (
--fmt cljfmt): ports the default-rule pipeline of cljfmt 0.16.6 (EPL-1.0, pinned at commitbaab500) over a node/zipper model mirroring rewrite-clj 1.2.50 (Apache-2.0). The pinned rule tables (clojure / compojure / fuzzy) and the byte-exact differential snapshots are committed undertests/cljfmt-diff/(provenance:tests/cljfmt-diff/manifest.edn).
cargo build && cargo test # 205 tests across 11 suitesStandard gates: cargo build · cargo clippy --all-targets -- -D warnings -W clippy::unwrap_used -W clippy::expect_used -W clippy::panic -W clippy::unreachable -D clippy::todo · cargo test. Panicking
constructs are lint-denied with auditable allowances; release builds run
with overflow-checks = true; a residual panic is caught at the dispatch
boundary and becomes an internal-error envelope — never a crash, never a
partial write. No network, no Clojure runtime, nothing is ever evaluated.
SPEC.md is the complete contract: envelope shapes, the addressing model,
safety invariants (I1–I6), the recovery view, the design-decision log, and
the test map.