8000 GitHub - gtrak/cljform · GitHub
[go: up one dir, main page]

Skip to content
gtrakPublic

About

No description, website, or topics provided.

Resources

Contributing

Stars

5 stars

Watchers

0 watching

Forks

Latest commit

 

History

129 Commits

Folders and files

Repository files navigation

cljform

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.

Why build this? (human-written)

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.

A thirty-second tour

$ 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 table

The 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 untouched

Every 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).

The model

  • Handles are content-addressed. tree marks each form with ⟦handle⟧; that handle is the only address edit accepts. 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; --repair applies 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; --strict turns warnings into refusals for CI.

CLI

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.

When files break: conflicts and recovery

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 parses

Every 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.

Detectors

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.

The pi extension

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.md

Acknowledgements

The 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 commit baab500) 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 under tests/cljfmt-diff/ (provenance: tests/cljfmt-diff/manifest.edn).

Development

cargo build && cargo test        # 205 tests across 11 suites

Standard 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.

About

No description, website, or topics provided.

Resources

Contributing

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

0