Changelog
Unreleased
Added
llms.txt, a short guide for coding agents: the commands to use, how clove differs from JVM Clojure, what libraries load, and where the docs are. The docs site serves it at its root.- A docs page on
clove judgesnapshot tests. - The docs site is published to GitHub Pages on every push to main.
Changed
- The esoteric-language interpreter example is now
examples/tape.clj("Tape Machine"). subvecis an O(1) slice sharing the vector's structure (it copied).seq,firstandrestof a vector no longer copy it into a list:(first v)on a million-element vector took 26 ms.seq,rseq,subseqandrsubseqof a sorted collection walk the tree lazily, andsubseq/rsubseqseek to their start key in O(log n) and stop at the end bound.(first (subseq s >= k))on 100k elements took 12 ms.- The fiber scheduler's ready queue pops in O(1): 8,000 futures took 25 s and 50,000 ran out of memory.
- Symbol and keyword intern tables grow; 200,000 distinct keywords took 10 s.
- The heap shrinks: the collector returns empty cell blocks, empty environment blocks and surplus recycled arrays to the OS instead of keeping its peak size forever.
(gc)and the new(System/gc)keep only a small margin; automatic collections release blocks the last cycle never reached. A dropped 5M-element vector goes from 500 MB RSS to 65 MB after one(gc)(it stayed at 580 MB). - Smaller heaps for small live sets: the adaptive collection floor stays within 16x the live data, and headroom over a large live set is capped at 16M cells. A 5M-iteration
looppeaks at 72 MB instead of 530 MB (system time 0.29 s to 0.03 s); the test suite peaks at 2.7 GB instead of 5.2 GB. (clove/heap-bytes*)reports the bytes the collector has mapped for cells.- An idle program gives its heap back without calling
(gc): before blocking inThread/sleep, the fiber scheduler's wait,read-lineorslurpof stdin,tcp/accept/tcp/recv,sh, or the REPL and nREPL waiting for input, the collector runs a releasing collection if the program allocated since memory was last given back (at most one a second). A 300 MB churn followed by(Thread/sleep 50)ends at 60 MB. - A finished coroutine frees its 1 MiB stack at once rather than at the next collection (200,000 short coroutines peaked at 2 GB).
- Hashing or comparing a map or set no longer builds an entry list.
- Transient maps and sets are real:
assoc!,conj!,dissoc!anddisj!edit in place instead of falling back to the persistent ops.frequencies,group-by,into,set,zipmapandupdate-vals/update-keysbuild through them: at a million elementsfrequencieswent from 2.6 s to 0.5 s and(into #{} ...)from 2.6 s to 0.6 s. A transient map or set is no longermap?/set?, andpersistent!of one carries no metadata, as on the JVM. - Replacing a value in a big map copies only the trie path, not the insertion-order index too: persistent
update/assoc-inloops are about 1.6x faster. - Dense levels of a big hash map or set are array nodes (32 child slots, as in Clojure's
PersistentHashMap), so an update copies half the bytes along its path; removing a key no longer looks it up twice; and a map that keeps dropping its oldest key compacts its insertion order a quarter as often. A dissoc/assoc loop on a 100k-entry map went from 2.7 s to 1.8 s. frequenciesandgroup-byare natives: each element is one lookup and an in-place update, group-by's groups grow as transient vectors, and a lazy input is consumed as they go instead of being held whole. On a million elementsfrequencieswent from 0.43 s to 0.22 s andgroup-byfrom 0.57 s to 0.25 s.updatetakes up to three args before the rest, andfniltakes two or three defaults, as in Clojure.(update-in m [] f)updates thenilkey as on the JVM (it appliedfto the whole map).- Faster calls into interpreted fns: arity selection and parameter binding are cached per arity, a leaf fn (no closures or lazy seqs in its body) gets its frame on the C stack, and tail calls to natives skip the trampoline.
update,update-in,assoc-inandnextare native. 1M-stepupdate-inandassoc-inloops are about 2x faster. - Deeper recursion before StackOverflowError: about 19,000 compiled frames (was 3,000) and 6,000 through
try(was 670) on an 8 MB stack.
Fixed
- The collector no longer keeps dead data alive through its own stack frame (uninitialized words, and the unwritten signal-mask part of the
jmp_buf) or through words that hold the high half of an old heap pointer over a small int; cell blocks now avoid the addresses those words point at. Each kept a dropped structure alive in some runs. - Strings containing NUL bytes compared, hashed and interned (as keywords and symbols) only up to the first NUL:
(= "" (str (char 0)))was true. - Hashing a double no longer has undefined behavior for NaN, infinities and values beyond the long range.
=andhashon deeply nested data inside a coroutine (go block, future) throw StackOverflowError instead of overrunning the coroutine's stack and corrupting memory. Coroutine stacks now have a guard page.- A list ending in a lazy tail, such as
(cons 1 (lazy-seq [2])), hashes like the equal vector. subseqandrsubseqreturn()rather than nil where Clojure does.CLOVE_GC_LOGcounts every cell type in its histogram.- Dead data reachable only from stale words on the C stack is collected in more cases: a vector built in a
loopand dropped stays live no more, and(first (filter pred (iterate inc 0)))no longer retains every element before the match (1.1 GB at 3M elements, now 26 MB). Realizing a long chain of lazy seqs forwards each step instead of keeping it. Natives are called without anapplyframe in between, so clang builds (macOS) release theiteratecase too. The stack a call dirtied is zeroed before the next native call, between body forms and afterletbindings (in compiled fn bodies too), and before each collection, so dropped data is freed on every platform, Windows included (mainno longer keeps a 32 KB never-written array in its frame). Call-heavy loops pay up to ~10%.
Fixed (JIT)
jit/compile!no longer changes what a function returns. Aletthat shadows a name read the uninitialized new variable; comparisons returned1/0instead oftrue/false;0counted as false;(= 1 1.0)was true;min/maxignored all but two arguments; long arithmetic wrapped on overflow instead of throwing;quot/rem/modby zero killed the process with SIGFPE;##Inf/##NaNliterals failed to compile;(+)and(*)were rejected. Loop bindings now see the ones before them, and self-calls work inside aloop. Forms whose compiled answer could not match (mixed long/doubleifbranches ormin/max,ifwithout else, arecurchanging a loop variable's type, a^longreturn hint on a double body) are rejected instead. Overflow checks make fib(32) ~12 ms instead of ~4 ms.- Variadic arithmetic over mixed number kinds folds left as Clojure does:
(+ Long/MAX_VALUE 1 1.0)throws "long overflow" instead of returning a double.
Fixed (host IO, processes, batteries)
spitandslurpreport IO errors like the JVM:(spit "/dev/full" "x")throwsIOException"No space left on device" (it returned nil), and(slurp "/tmp")throwsFileNotFoundException"/tmp (Is a directory)" (it returned ""). Open failures areFileNotFoundExceptionwith the OS reason.shandclove/sh-lines*report a child killed by a signal as 128+signal ((sh "kill -9 $$")gave exit 0).~/.clove_historyis created with mode 0600, and an existing one is tightened to it.- The nREPL server (
clove nrepl) rejects a bencode string length that is not all digits or exceeds 64 MB. A length past 2^64 wrapped around and overflowed a heap buffer. fs.cljno longer runs paths throughsh -c:(fs/directory? "x$(touch pwned)")ran the command. It now uses clove's file natives, so it no longer needs a C compiler to load (neither doesclove vendor, which uses it). The libc globals (getpid,cwd,env…) are no longer pulled in by loading it.- Network framing counts bytes, not characters:
nrepl.cljbencode ((nrepl/bencode {"value" "\"é\""})gave3:),csp/send!andtcp/send-some*(which also stopped at a NUL), and the clerk server's Content-Length. http.cljreads a request in linear time (it rebuilt the buffer on every chunk) and answers 413 pasthttp/*max-head-bytes*(64 KB) orhttp/*max-body-bytes*(16 MB).claude.cljpasses the API key to curl in a 0600 header file (-H @file, deleted afterwards) instead of on curl's command line, where any local user could read it withps. Needs curl 7.55 or later.clove bundleruns the compiler with an argument vector, so an output path with spaces or quotes works, and it deletes the generated.gen.ceven when the compile fails.- Windows:
clojure.java.shell/shand multi-argumentshstart the program withCreateProcessinstead of acmd.exeline, so an argument likea" & calc & "stays one argument and%VAR%is not expanded. Arguments are quoted by the MSVCRT rules, temp files are created atomically and delete themselves, and:envworks. As on the JVM, cmd builtins (echo,dir) are not programs and needcmd /c. - Windows:
clove runrefuses task arguments containing",%or a newline, whichcmd.execannot quote, andclove newcreates directories without a shell. clove/proc*no longer leaks pipe descriptors whenpipeorforkfails.
Fixed (evaluator, special forms, prelude)
- A later
letbinding of a name no longer changes what an earlier closure or lazy seq in the sameletsees:(let [x 1 f (fn [] x) x 2] (f))is 1.letfnis now a special form (letfn*) instead of relying on that leak. recurin a variadic fn binds the rest param to the value given, as Clojure does; it used to wrap it in another list, so loops over& morenested, errored or never ended.distinct,partition(every arity),partition-all,sequencewith a transducer,eduction,replaceon a list,drop-whileandsplit-withare lazy, so they work on infinite seqs.distinctwas also quadratic.(cycle [])is()instead of hanging.str/joinwith a separator is linear; it was quadratic.butlastandtake-lastreturn nil, not(), when nothing is left.condpsupports:>>, and with no match it throws "No matching clause: x".- Namespaced map destructuring:
{:a/keys [b]},{::keys [b]},{:a/syms [b]}. merge-withof only nils is nil;mergetakes a[k v]entry;(flatten {:a 1})is();(range 0 10 0)repeats 0 instead of throwing;(drop-while odd? [1])is().bindingis per fiber. It used to overwrite the var's root, so two fibers binding the same var (a future and its caller) clobbered each other and could leave the root changed for good.bound-fn,get-thread-bindingsandwith-bindingsnow seebinding, andfutureconveys the caller's bindings to its body, as on the JVM.set!on a var that nobindinghas bound throws, as on the JVM (the vars clojure.main binds, like*warn-on-reflection*, still work).set!on a local throws instead of silently redefining a global.
Fixed (numbers, reader, regex, printer, format)
(quot Long/MIN_VALUE -1),remandmodcrashed the process with SIGFPE; they now wrap like the JVM.quot,remandmodon doubles and ratios no longer truncate to longs:(mod 7.5 2)is 1.5,(rem 7/2 1)is 1/2.- BigInt division is limb-wise instead of bit-by-bit: summing
(map / (range 1 1500))went from 45 s to 0.3 s. Parsing long BigInt literals is ~15x faster. - Number literals must parse as a whole:
[12abc 3],1.2.3,1e,2r2,0xand1/-2are read errors instead of truncated values. Hex and radix literals past a Long read as BigInts (0xFFNis255N), and(bigint "12abc")throws. - String literals:
"\uD83D\uDE00"is one character,\uneeds four hex digits, and octal escapes ("\012") work.\o400is rejected. - Reading, printing and regex matching deep or long input inside a
futureraises a catchable stack-overflow error instead of segfaulting. - Regex:
x*,\w+and.*loop instead of recursing per character, so long inputs no longer hit "too much backtracking".[\D],[\W]and[\S]match, unknown escapes like\ythrow,$matches before a final newline, and.skips\r. New:\p{…},\Q…\E,\A\z\Z,\xHH\uHHHH\0ooo\e\cX\h\v\R, scoped flags(?i:…),[]a], class intersection, possessive quantifiers, atomic and named groups,a{100}and longer classes of non-ASCII characters. formatpads and truncates%s/%cby characters, not bytes;nilformats asnull;(format "%d" 2.0)throws.str,pr-strandupper-casekeep the bytes after a NUL in a string;pr-strescapes\band\f.Math/roundsaturates at the Long range and rounds 0.49999999999999994 to 0.(max ##NaN 1)is##NaN.parse-doublefollowsDouble/parseDouble("Infinity"," 1.5 ","0x1p3"; not"inf").(char -1)throws.- A trailing
#_format the end of input reads as nothing, andread-stringtakes an options map (:eof).
Fixed (reader metadata, futures)
- Reader metadata is data:
^m formattachesmto the form instead of reading as a(with-meta form m)call, so(meta (read-string "^:k [a]")),(meta '^:k [a])and a macro's(meta x)see it. Collection literals andfnforms evaluate their metadata ((meta ^{:x (+ 1 2)} [1])is{:x 3}),defevaluates its name's, syntax-quote carries it, and metadata on a call form is dropped, as in Clojure.^:k 1is a read error. - Dereferencing a future whose body threw throws a
java.util.concurrent.ExecutionExceptionwith the original as itsex-cause, as on the JVM; it rethrew the original.pmapandpcallswrap the same way. Code that caught a specific exception type around@futnow needs(ex-cause e). - ASan builds no longer report false stack-buffer-underflows after a throw caught inside a coroutine: the frames a throw abandons have their redzones cleared, which ASan does not do on a coroutine stack.
v0.9.0 — 2026-11-02
Changed
- Notebooks and standalone pages use the clove brand: palette from the new logo (
brand/), rounded headings, automatic dark mode, and a small "made with clove" footer. Code no longer renders font ligatures (->>stays three characters).
Fixed
- Startup is back to about 8 ms, from 10 (it regressed in 0.8.0 as the core library grew). Global definitions and lookups went through a linked list of every global, about 2,000 of them; they now use a hash index. Symbol interning keeps each name's hash and length, so a lookup no longer calls
strlenon every name in the bucket. - Programs started from clove (
sh,clojure.java.shell/sh,process) inherited the 1 GB stack limit clove sets for itself, and threaded ones could fail to start a thread (headless Chrome did). They now get the limit clove started with. (doc with-redefs)has a docstring.- Notebook markdown: a list item that wraps onto the next line stays one item (it used to break into a stray paragraph). Added fenced code blocks (Clojure fences are highlighted) and
>block quotes.
Added
- A documentation site, built from notebooks in
doc/with./clove doc/build.clj: tutorial, guides, a generated reference, a cheat sheet (with a printable PDF) and an in-browser playground. Code on every page can be edited and re-run in the browser. - Notebook markdown renders
| tables |. - One-line install:
curl -fsSL https://raw.githubusercontent.com/alexalemi/clove/main/install.sh | shdownloads the newest tagged source, builds it, and installs to~/.local.--version(a tag ormain),--prefix, and--testadjust it; run from a checkout,install.shstill builds that checkout and runs the suite. - Notebooks: percent cells. A
;; %%line in a notebook switches it to grouped cells: a code cell runs everything up to the next;; %%, prose block, or directive, and shows only its last form's value (end a cell withnilto show nothing). Notebooks without a;; %%line still make every top-level form its own cell. clove notebook nb.clj -o nb.html -Swrites a standalone page that embeds clove compiled to WebAssembly: the page re-runs its cells on load, and any cell can be edited and re-run in the browser. Needs emscripten (emcc, orCLOVE_EMCC) once; the build is cached.--wasm-url <url>references a hosted interpreter instead of inlining its ~1.3 MB, andclove notebook --wasm out.jswrites that file. Notebook flags may now come in any order.
v0.8.1 — 2026-11-01
Fixed
- Windows:
.mkdirsandclojure.java.io/make-parentscreated nothing when the path started with a drive letter (D:\...). v0.8.0 shipped no binaries because of it; this is the release of that work.
v0.8.0 — 2026-11-01
Changed
- Maps keep insertion order at every size. Small maps (up to 8 entries) are now array maps, as on the JVM: they print and iterate in insertion order, where they used to come out reversed. Larger maps keep insertion order too: the hash trie points into an ordered entry vector, so
keys,seqand printing follow insertion order (the JVM switches to hash order past 8). Replacing a value keeps its key's slot. Lookups and iteration cost the same as before; churn-heavydissocon large maps is about 2x slower. Records print their fields in declaration order.
Fixed (silently wrong before)
spitwith:append trueappended; it used to overwrite the file.atomhonours:validatorand:meta;set-validator!andget-validatorexist. An invalid state throws IllegalStateException "Invalid reference state".clojure.java.shell/shexists and runs an argv, with:in,:env,:dir, a separate:err,with-sh-dirandwith-sh-env. Coreshwith several arguments used to drop all but the first.*command-line-args*holds the script's arguments; it was always nil.with-in-strfeedsread-lineandread; they returned nil.*in*(andSystem/in) is stdin:(slurp *in*)reads it (it tried to open the file ""),(line-seq (io/reader *in*))streams it, andreadon a pipe keeps the rest of the line for the nextread/read-line(it used to drop it).(.getName file)is the last path segment, not the whole path. File is now a value (java.io.File.,io/file) with.exists,.getParent,.listFiles,.mkdirs,.delete,.length,File/createTempFile,file-seq, andstrgives its path..getBytesreturns a UTF-8 byte array, not characters.make-arrayofLong/TYPE,Double/TYPEand the other primitive types fills with zeros, not nil.strof a regex is its source (it included the#"…"wrapper).strof an exception is"java.lang.Exception: msg", andpr-strprints#error {…}. Both used to print a raw map..printStackTraceprints the exception and its causes to*err*(it printed nothing).macroexpand-1expandswhen,cond,and,orand the other natively implemented macros, as the JVM shows them.map->Rgives a record's absent fields nil, so(= (map->R {:a 1}) (->R 1 nil))holds andcontains?sees every field.cl-format~rspells numbers ("four","twenty-first","MCMXCIV"); it printed digits.
Added
- clojure.core:
printf,newline,read(also from a PushbackReader),read+string,load-string,load-reader,parse-boolean,infinite?,NaN?,bound?,memfn,with-local-vars/var-get/var-set,tap>/add-tap/remove-tap,partitionv,partitionv-all,splitv-at,iteration,amap,areduce,Throwable->map,with-precision(BigDecimal division and arithmetic round to the precision),char-array,into-arraywith a type, and:docmetadata on core vars. Math/floorDiv,Math/floorModand the*Exactfns.(Math/PI)and other static fields work in call position.- Regex flags
(?i),(?m)and(?u);re-matcher,re-groups, and Matcher.find/.start/.end. format: the,grouping flag (%,d,%,.2f),%b,%c,%S,%h,%n, and%2$sargument indexes.- String methods:
.split,.isEmpty,.isBlank,.equalsIgnoreCase,.repeat,.lastIndexOf,.strip,.matches,.replaceAll,.toCharArray,String/join,String/format, and others. Character:isLetter,isUpperCase,isLowerCase,toUpperCase,toLowerCase,getNumericValue, and friends. java.utilcollections (ArrayList,LinkedList,ArrayDeque,HashMap,LinkedHashMap,TreeMap,HashSet,TreeSet, …) work withseq,count,into,get,=and printing.java.time.LocalDate(parse, of, now, plus/minus, day-of-week, epoch days) andChronoUnit/DAYS .between.slurpof a Reader;clojure.java.io/copy,make-parentsanddelete-fileon Files.clojure.pprint/print-table, andcl-formatwith~a ~s ~d ~b ~o ~x ~r ~f ~e ~$ ~c ~p ~t ~* ~% ~& ~{~} ~^ ~[~] ~(~).
Fixed
- Vars keep their metadata.
defused to drop the metadata on its name; it is now stored with the var and merged into(meta #'x), evaluated as Clojure evaluates it (^{:test (fn [] ..)}stores a fn; a^Stringhint stays the symbol).defn's attr-map lands there too, a redef without metadata resets it, andalter-meta!/reset-meta!work on vars. So(test #'x)runs a var's:test. - An instance's own method beats a host shim of the same name. clove defines some JVM methods globally (
.write,.get, …), and those used to shadow areify/proxy/deftypemethod of that name:(.write w s)on a reified Writer called the shim. The instance now wins, as an override would on the JVM. Only names some type actually implements pay for the check, so other.methodcalls run at full speed. - **
byte-arraymakes byte arrays**: values wrap to -128..127 as Java'sbyteValuedoes,bytes?is true of them (it was always false), and they print as#<byte-array n>. - **
munge,namespace-mungeanddemungemangle** with Clojure's character map ((munge "a-b?")is"a_b_QMARK_"); they returned their argument unchanged.clojure.main/demungeis an alias. - **
supers** returns a type's ancestors from the interfaces itsdeftype/defrecorddeclared, or nil (it was always#{}); **bean** returns:classplus a typed instance's fields (it was always{}).
v0.7.0 — 2026-10-31
Renamed: cljc is now clove
The old name collided with the .cljc file extension. The binary is clove, the source is clove.c, the share dir is $PREFIX/share/clove, and the environment variables are CLOVE_PATH, CLOVE_CC, CLOVE_GC_STRESS, … The REPL prompt is clove[n]>. ~/.cljc_history moves to ~/.clove_history the first time the REPL starts. Internal helpers moved from cljc/… to clove/…, and the C embedding API from cljc_*/Cljc to clove_*/Clove. The reader-conditional feature is :clove; :cljc is still accepted, so existing #?(:cljc …) code keeps working. .cljc files load as before.
Changed — behavior that used to diverge silently from Clojure
- Integer overflow throws.
+,-,*,incanddecraiseArithmeticException"long overflow" when the result leaves the int64 range, as Clojure does; they used to wrap, so(* 4611686018427387904 4)was0.+'/-'/*'/inc'/dec'still promote to a bigint, andunchecked-add/-subtract/-multiply/-inc/-dec/-negateare now the ones that wrap (they were aliases of the checked ops). Measured cost on the arithmetic benchmarks: 0–4% (fib, fib32 within noise; the tightloop/recurcounter 4%). Code that relied on wrapping — hash mixers, LCGs — needs theunchecked-forms, as on the JVM. - **
catchis typed.** The firstcatchclause whose class the exception is-a handles it, and an exception no clause matches is rethrown (afterfinally);catchused to ignore the class, so the first clause took everything. Classes are recorded onex-info(ExceptionInfo), on the exception constructors (IllegalArgumentException.,IllegalStateException.,Exception., … plus newThrowable.,Error.,ArithmeticException.,NumberFormatException.,IndexOutOfBoundsException.,NullPointerException.,ClassCastException.,InterruptedException.), onassert/:pre/:postfailures (AssertionError— anError, so(catch Exception e …)no longer swallows a failed assertion), and on the interpreter errors with an obvious JVM class: division by zero and overflow (ArithmeticException), index out of bounds, number parsing, nil or a non-number where a number goes (NullPointerException/ClassCastException),case/condpfall-through and missing multimethods (IllegalArgumentException), stack overflow (StackOverflowError). Other interpreter errors stay untyped and match any clause exceptExceptionInfo/AssertionError/Error.classandinstance?report the recorded class; the class survives rethrow,finally, and futures.(is (thrown? SomeClass …))in the test battery checks the class too. - **
Integer/parseInt,Long/parseLong,Double/parseDoublethrowNumberFormatException** on malformed input; they returned nil, so(try (Integer/parseInt s) (catch NumberFormatException _ 0))yielded nil.Integer/parseIntalso rejects values outside the 32-bit range, and the radix arities accept a sign.parse-long/parse-doubleremain the nil-returning ones. - Printing bigints and decimals.
print/println/print-stremit theN/Msuffix likeprdoes (1.5M, not1.5);strof a bare number still drops it. Andstrof a collection prints its contents readably:(str ["a" 1.5M])is"[\"a\" 1.5M]", where it was"[a 1.5]". - BigInt is a type, not a size.
1Nis a BigInt (it read as the long1), and BigInt arithmetic stays BigInt whatever the magnitude, as Clojure's contagion does:(+ 1N 1)is2N, and(reduce * 1N (range 1 30))returns8841761993739701954543616000000Nwhere it threw "long overflow". A whole result of ratio arithmetic is a BigInt too ((+ 1/2 1/2)is1N), while long division stays long ((/ 4 2)is2).bigintandrationalizeof a double return BigInts;(bigint 1e30)is exact. Longs and BigInts are still one category:(= 1 1N), equal hashes, the same map/set key, and a small BigInt works as an index ((nth v 1N)). Visible change:pr-strof these results now carries theN, andint?is false for them (integer?is true).bigintegerreturns the same type, so it prints3Nwhere the JVM'sBigIntegerprints3. - Comparator results are read by sign. A
sort/sort-bycomparator returning a BigInt, or a long beyond 32 bits ((fn [a b] (- a b))over large values), used to be truncated to a Cint, which could flip or zero the ordering.
Added
- A multi-line REPL editor, with vi mode. Enter runs a complete form and otherwise opens a new line indented for the open brackets; Alt-Enter / Ctrl-J always add a line; ↑/↓ move between lines and walk history past the first/last, and history keeps whole multi-line forms (
~/.clove_historymoves to an escaped v2 format, upgraded in place). Brackets and quotes auto-pair (typing a closer steps over it, Backspace removes an empty pair), pastes arrive verbatim (bracketed paste),\(is a char, not a bracket, and the arglists of the call around the cursor show dimly after the text ((map inc▏) [f] [f c] [f c1 c2] …). vi mode —CLOVE_EDIT_MODE=vi,set editing-mode viin~/.inputrc, or(clove/edit-mode :vi)— has insert/normal with cursor shapes, the usual motions (hjkl w b e W B E 0 ^ $ f F t T ; , % gg G),d/c/ywith counts, motions and text objects (iw aw i( a( ib i[ i{ i" a"…),dd cc yy D C Y x X s S r ~ J p P o O,u/Ctrl-R, and..make test-ptydrives it through a real pty. - **A bare
?right after an error** asks Claude to explain that error (the REPL hints(? explains)when a key is set). - **
(inspect x)** opens a full-screen data inspector (inspect.clj): breadcrumbs,j/k/PgUp/PgDn/g/Gto move, Enter/linto a map entry, element, or atom's value,hback,qreturns the value being viewed. Lazy and infinite seqs realize only what's on screen. Without a terminal it pprints. - Test failures show where values differ.
(is (= expected actual))evaluates each side once and printsexpected:/actual:and a path diff ([:roles 1] expected :dev, got :ops, at most 10 lines); the header names the test (FAIL in (users)).clove/diff-pathsgives the diff as data. - **Project tasks, like
bb tasks.**clove run <task>runs a task from the nearestclove.edn({:paths [...] :tasks {test "clove test" ...}}; strings are shell commands, lists are Clojure forms, maps take:task:doc:depends:interactive), with dependencies run once each in order, args passed to the named task, the failing status propagated, and a did-you-mean for typos.clove taskslists them;clove new my-appscaffolds a project (clove.edn, src/, test/, README).clove.edn's:pathsjoin the load path likedeps.edn's. - **
? questionat the REPL asks Claude.** The question goes out with the session transcript (inputs, results, errors, earlier answers; last 32 KB) and a system prompt describing clove; the answer streams in. Claude's tools:clove_lookup(doc/source/dir/apropos/find-doc/var value — read-only, runs without asking) andpropose_form, which writes one line into your prompt to edit, run with Enter, or decline with^C— Claude never runs code itself. The answer is numbered like a result and held in*1. Implemented in the newclaude.cljbattery ((claude/ask "...")works from code too) overcurl; needsANTHROPIC_API_KEY. Usesclaude-opus-5-5with server-side refusal fallbacks. POSIX shells only for now (the key is expanded as$VAR). - **
clove/sh-lines*** runs a shell command and calls a fn on each output line as it arrives (what the streaming above is built on). - **
make fuzz/ nightly fuzz workflow.**fuzz/gen_expr.pygenerates random typed core expressions andfuzz/fuzz.shruns them (plus the string corpora) through clove and babashka and diffs the output; a GitHub workflow runs it nightly with fresh, printed seeds. Its first runs found the six divergences listed under Fixed below. clove -e A -e B— a repeated-eseparates expressions (it was read as a symbol).- **
java.math.BigDecimal**, as a real numeric type: unscaled integer + decimal scale, so the arithmetic is exact.1.5Mliterals;+ - * /,quot/rem/mod,inc/dec,compare,==and=all accept it, with Java's scale rules ((+ 1.5M 1.5M)is3.0M,(/ 150M 1E+2M)is1.50M) and Java's exact division —(/ 1M 3M)throws "Non-terminating decimal expansion" instead of guessing.=andhashgo by value, so(= 1.0M 1.00M)and#{1.0M}finds1.00M, as in Clojure. Mixed with integers or ratios the result stays a BigDecimal; a double makes it a double. Prints1.5Munderpr,1.5understr, with Java's switch to1E+3notation.number?/decimal?/rational?are true;bigdec,double,long,rationalizeandformat "%.2f"(exact HALF_UP, so1.005M→"1.01") all convert it. Interop:BigDecimal.,ONE/ZERO/TEN,MathContext.,RoundingMode/HALF_UP, and.add/.subtract/.multiply/.divide/.setScale/.scaleByPowerOfTen/.abs/.negate/.compareTo/.precision/.scale/.unscaledValue/.toPlainString/.doubleValue. Differentially fuzzed against the JVM: 2000 random cases x 25 operations, identical. - **
java.util.Random**: the JVM's exact 48-bit LCG —.nextDouble,.nextLong,.nextInt(both arities),.nextBoolean,.nextFloat,.nextGaussian(Marsaglia polar, with Java's cached second deviate),.setSeed. A seeded stream matches the JVM value-for-value, which is what ported numeric code pins its expectations to. - **Real
double-arrays.**double-arraywas(def double-array int-array): callable, but it could not hold a double. Arrays now carry an element-type flag and store IEEE bit patterns in the same 8-byte slots — same allocation, sweep, and GC treatment, with only the read and write ends aware of which kind it is.float-arrayfollows;int-arrayis unchanged. Math/signum(NaN and both zeros pass through, as in Java).
Fixed
- **
#instand#uuidliterals read.** The reader had no tagged literals, so#inst "2020-01-01"was an unknown symbol. They now read as values, in code and inread-string/clojure.edn/read-stringdata alike:#instis ajava.util.Date(inst?,inst-ms),#uuida UUID (uuid?), and both print back as their literals.random-uuidandjava.util.UUID/fromStringreturn UUIDs rather than strings (usestrfor the text), andparse-uuidis new. Any other tag raises "No reader function for tag …", except inside the skipped branches of#?, where it is ignored as in Clojure (so#?(:cljs #js {} :clj …)reads). - **
proxybuilds a working object.** It was an inert atom; it is now areifyof the given methods, each with proxy's implicitthis, so(str (proxy [Object] [] (toString [] "hi")))is"hi". Constructor args are ignored (there is no superclass to construct).ThreadLocalproxies keep their atom behavior. - **
shuffleshuffles.** It was a stub that returned(vec coll)unchanged; it is now a Fisher–Yates shuffle. Therandseed also mixes sub-second time with the pid, so processes started in the same second no longer get the samerand/shufflestream. - **
add-watch/remove-watchwork on atoms.** They were no-ops on atoms (agents and refs already worked); a watch is now called as(f key atom old new)after everyswap!,reset!,swap-vals!,reset-vals!and successfulcompare-and-set!. - **
pop!pops.** On a transient vector it returned its argument unchanged; it now removes the last element in place, and throwsIllegalStateException"Can't pop empty vector" on an empty one. - **
compare-and-set!compares by identity**, as Clojure does, rather than with=:(compare-and-set! (atom [1]) [1] 2)isfalse. - **
special-symbol?** is true for the JVM's special forms (if,let*,fn*,., …); it was always false. - **
tagged-literal?andreader-conditional?** recognize the values thattagged-literalandreader-conditionalbuild; they were always false. - **
unchecked-byte,unchecked-shortandunchecked-charwrap** like Java's narrowing casts ((unchecked-byte 200)is-56,(unchecked-char 65)is\A); they were aliases ofint. - **
test** calls the:testfunction in the metadata and returns:ok, or:no-testwhen there is none; it always returned:okwithout running anything. - **
(long ratio)out of range** throwsIllegalArgumentException"Value out of range for long: <quotient>", as Clojure does, instead of an untyped error that anycatchclause took (found by the nightly fuzzer). - **
=between a finite and an infinite seq** returns false instead of hanging:(= [0 1 2] (range)). - Long names, messages and format output are no longer cut off. Fixed C buffers silently truncated: interpreter error messages at 255 chars; namespace-qualified names (a
defin a namespace,::kw,#:ns{}map keys,(symbol ns name), syntax-quote and var lookup) at 255;gensymnames at 63;formatoutput with a width (%5s) at 511; and a decimal literal over 63 chars was a "number too long" error. Each now grows to fit. The differential fuzzer generates long names so this stays covered. - **
%fof a double formats like Java.** It rounds the double's shortest decimal form HALF_UP ((format "%.1f" 0.25)is"0.3",(format "%.20f" 0.1)has no...555tail), and NaN/infinity print asNaN/Infinityinstead of C'snan/inf. Found by the fuzzer's new long-number cases. - Found by the new fuzzer:
subvecnow checks its bounds (it clamped silently); ratio→double conversion follows Java'sRatio.doubleValue(16 significant digits, then nearest double), so(/ 1.5 8/2181825)prints409092.18749999994as on the JVM;(str/replace s "" r)inserts between every character instead of erroring;(long 1e19)andlongof an out-of-range bigint throw "Value out of range for long" (the cast was undefined behavior in C);(abs Long/MIN_VALUE)isLong/MIN_VALUE. - **A pending exception survives
finally.** The exception in flight lived only in globals, so afinallybody that threw and caught an error of its own replaced what was rethrown afterwards. - Error traces keep the frame of a function that tail-calls a native.
(defn f [x] (+ x 1))failing inside+showed(+ ...)directly underf's caller: the native's frame replacedf's instead of stacking on it. - Keyword lookup missed sorted maps and sets.
(:k (sorted-map ...))and(:k #{:k})returned nil while(get ...)found the entry — a silent wrong answer. Clojure'sKeyword.invokeis exactlyRT.get, so it now delegates there and covers every collectiongetcovers. - Map literals now evaluate in source order. Entries were evaluated in HAMT hash order, so
{:result (f) :values @acc}readaccbefore runningf. Clojure evaluates small map literals left to right (they are array-maps, whose seq is insertion order); the reader now keeps its original k/v form list — which it already built — and eval walks that. - Integral doubles coerce at index positions, following
RT.intCast:(nth v 1.9)is index 1 and(subs s 1.7)drops one character, as in Clojure. Numeric code that computes an index in floating point relies on it. The JIT/FFI bridge stays strict on purpose — a^longfunction handed 2.5 must not quietly compute on 2 and disagree with its interpreted twin. format%g/%Gfollow Java, which keeps trailing zeros, rather than C, which strips them:(format "%.4g" 2.0)is now"2.000".%Ealso works.inc/decno longer fail on bigints and ratios —(inc 99999999999999999999N)and(inc 1/2)now work instead of erroring on the int64 fast path.bigintis idempotent:(bigint (bigint x))no longer round-trips throughintand caps at int64.- **
aseton an int array refuses a non-integer.** Since index positions started coercing,(aset (int-array 2) 0 2.5)stored2without a word; it now throws "argument type mismatch" like the JVM.(int-array [1.5])still truncates, as Clojure's does. - Map literals reject keys that collide at runtime:
(let [x 1] {x 1 1 2})throws "Duplicate key: 1" instead of silently keeping one entry.
v0.5.1 — 2026-09-13
Added
- Shebang support for executable scripts.
#!now reads as a comment to end of line, matching Clojure's reader (which registers!in the dispatch table as a comment reader), so#!/usr/bin/env cljc+chmod +xgives a directly runnable script. Because it lives in the reader's whitespace skipper rather than in the script entry point, it holds on every path —cljc file.clj,load-file,require, stdin,cljc lint,cljc watch, and the source embedded bycljc bundle— and anywhere in a file, not only line 1. The newline is left for the line counter, so error traces still report honest line numbers.
v0.5.0 — 2026-09-07
Added
- **
peg.clj** battery: Janet-style Parsing Expression Grammars —(require '[peg :as peg]), then(peg/match '(* (<- :w+) "=" (number :d+)) "k=7")⇒["k" 7]. Patterns are plain data (quote them; vectors work like lists), grammars are maps with:main, and the whole Janet vocabulary is there:* + ! any some opt between at-least at-most repeat if if-not look to thru sub split backmatch, captures<- group replace cmt constant argument position line column accumulate number drop backref/-> unref nth only-tags errorwith tags, andcompile/match/find/find-all/replace/replace-all. Semantics were checked case-for-case against the real Janet binary (144-pair corpus, now intests.clj), including the corners the Janet docs get wrong (if/lookkeep their captures; a no-progress iteration endsany/some;todrops,thrukeeps). One deliberate difference: positions and(<- 1)are codepoints, not bytes.lookwith a negative offset gives lookbehind, which the regex engine lacks. String.startsWith(prefix, toffset):(.startsWith s pre off)now takes the Java offset form, backed by a newcljc/str-starts-at?native that compares in place (no substring allocation) — the peg literal matcher's primitive.
Changed
- Non-ASCII strings index through a cursor cache:
nth/subs/str/index-ofon a string with multibyte chars used to walk from byte 0 on every call, so a sequential scan was quadratic (200k-char scan: 29 s). Each string now remembers its last (codepoint, byte) position and steps forward or backward from it (69 ms). Backward steps are taken only when the string is well-formed UTF-8 (a flag computed with the codepoint count); a stray continuation byte would desync a reverse walk, so dirty strings restart from the front. ASCII strings were and remain O(1). - Regex:
(?<— lookbehind or a named group — is now a loudregex: (?<...) is not supportederror. It used to parse as an ordinary group with a stray?, so(re-find #"(?<=a)b" "ab")silently returned nil where Java returns"b".
v0.4.2 — 2026-09-06
Fixed
compareon strings, keywords and symbols follows Java'sString.compareTo: the difference of the first differing character, or of the lengths when one is a prefix of the other. It returnedstrcmp's raw value, and C specifies onlystrcmp's sign — aarch64 glibc's word-at-a-time strcmp returns 64 for(compare "b" "a")where x86 returns 1. The prefix case was wrong on every platform, since strcmp compares against the NUL terminator:(compare "a" "aa")was0 - \a= -97 where the JVM gives -1; x86 only looked correct because glibc happens to return the byte difference for mismatches there. Sign was always correct, so sorting and comparators were unaffected. Found on Linux/aarch64 by the ides.club Claude session; the conformance corpus gains a prefix pair and a far-apart-chars pair, which would have caught the latent half on x86.
v0.4.1 — 2026-09-05
Security
http/servebinds loopback by default. It hardcoded"0.0.0.0"— every interface — so anything served with the battery was reachable from the whole network, and no argument could ask for anything else; running the test suite exposed its own port 8093 for the duration of the run. Both bindings answer identically on127.0.0.1, which is why it went unnoticed.tcp/listenalready defaults to loopback andnrepl.cljpasses it explicitly; this was the one place overriding that. Pass a host to opt back in:(serve 8080 h "0.0.0.0"), likewiserun-server. Breaking for anyone relying onhttp/servebeing reachable from another machine.
v0.4.0 — 2026-09-05
Security
- FFI module cache was a local code-execution vector on multi-user hosts. It lived at
/tmp/cljc-ffi4-<hash>.so, where the hash covers only(code, libs, cc)and is therefore identical for every user on the machine; cljc tested-fand thendlopened it with no ownership check. Any local user could precompute the hash, plant a.so, and have it executed inside another user's cljc process. The cache now lives in a private per-user directory ($TMPDIR/cljc-<uid>, created 0700 and refused if it exists and is not a directory this user owns), and a module is loaded only whentest -Oconfirms this user owns it. SettingTMPDIRalone was never a mitigation, since the paths were hardcoded. Only reachable by code that loads the FFI or JIT batteries. libc.cljwrote its shim to a fixed/tmp/cljc_libc_shim.h. Because/tmpis sticky, every user after the first could not overwrite it, breakinglibc.cljandfs.cljfor them; it was also a symlink-follow target. It moves into the same private per-user directory.- Added
(cljc/getuid)— native, used to scope that directory.
Added
(cljc/now-us*)— wall-clock microseconds since the Unix epoch, as an exact int64. Distinct fromnow-ms*, which staysCLOCK_MONOTONICbecause it backsfiber/timer!,csp/timeoutand every deadline: a wall-clock deadline stalls when NTP steps the clock. Returned as an integer, not a double — at epoch magnitude a double's ulp is ~0.5 µs, so microseconds would not survive.(cljc/getpid)— native, so norequireand nodlopen, and it works on Windows (GetCurrentProcessId) wherelibc.clj's FFI binding self-skips.cljc testwith no file arguments discovers*_test.clj/*_test.cljcunder., sorted, skippingvendor/target/node_modules/outand dotted dirs, and prints what it found. Explicit arguments still win.make test-fullruns the GC-stress pass;make testno longer does.- Raw terminal input natives for TUIs, no FFI needed:
(cljc/raw-mode* bool)enters/leaves raw mode (false when stdin isn't a tty; anatexithook restores the terminal on a hard exit),(cljc/read-key*)blocks for one keypress and returns its bytes — a whole UTF-8 character or a whole escape sequence ("\e[A"for Up; a lone Esc resolves after 30 ms), nil on EOF — and(cljc/term-size*)gives[rows cols]. Same termios recipe as the REPL's line editor. Windows builds get no-ops. - Specter runs (
com.rpl/specter+riddley): see COMPATIBILITY.md for the 92-case battery. The bring-up fixed six general interpreter issues below. - Reader conditionals honor the
:bbfeature (priority:cljc>:bb>:default>:clj): a library's babashka branch is its JVM-class-free path, which is what cljc can run. - Host class names for protocol dispatch:
java.util.List/Set,clojure.lang.PersistentTreeMap/PersistentTreeSet,IReduce,ITransientVector,Cons,IRecord(everydefrecordtype derives it),Sorted; sorted maps/sets derive the map/set interfaces.(type (transient []))is:transient-vector(was:unknown).
Changed
System/exitactually exits. It was(defn System/exit [n] nil): it returned nil and let the script run on, socljc script.clj || echo failednever fired whatever the script did. At a REPL prompt or inside an nREPL eval it unwinds with an error instead of terminating, so a loaded file callingSystem/exitcannot kill an editor's connection.System/currentTimeMillis,java.time.Instant/nowandjava.util.Date.report wall-clock time. They were derived from the monotonicnow-ms*, whose origin is boot, so they returned the machine's uptime —Instant/nowanswered1970-01-16T….System/nanoTimeis unchanged: Java specifies it as monotonic, so deriving it fromnow-ms*was already correct.Integer/toStringwith a radix is signed (-101, was101) andInteger/toBinaryStringis unsigned two's complement over 32 bits ((Integer/toBinaryString -1)→ 32 ones, was"1"); newLong/toBinaryStringis the 64-bit form. These are different functions on the JVM and one had been defined as the other, which dropped the sign:+2^54and-2^54printed identically.babashka.clishort flags take a value, as real babashka.cli does with or without:alias—-n 2is{:n 2}, was{:n true}. The value had also fallen through to the positional branch, so-n 2 fileyielded{:args ["2" "file"]}. This changes existing parses: a boolean short flag followed by a positional argument now consumes it; declare booleans via:coerceor use--flag.cljc testwith no arguments and no test files found exits 1, not 0. A green result that tested nothing passes CI whether or not the tests exist.
Fixed
- A top-level
(do ...)evaluates its subforms one at a time, as JVM Clojure does:(do (defmacro m ..) (m ..))— e.g. a#?(:clj (do ...))body — saw the macro use compiled before thedefmacroran (the head deopted at runtime but its args had been compiled as evaluations: "I don't know whatrichnavrefers to"). - Macro aliases through a Var work:
(def alias (var m))+(alter-meta! #'alias merge {:macro true})(specter'sdefmacroalias) now expands as a macro everywhere (tree-walker, VM,macroexpand-1,macro?); it used to be called as a plain fn returning the expansion unevaluated. - The empty vector is a singleton like
PersistentVector/EMPTY:(identical? [] (vector)),(empty v),(pop [x]),(persistent! (transient []))all hold (specter'sterminal*relies on it). Metadata on an empty vector takes a private copy. fnbodies whose list spine ends in a lazy seq — macro-built with(cons params (drop 2 m)),list*,concat— evaluated to nil: the clause/body walkers stepped raw cons tails and read the lazy tail as an empty body. Spines are realized in place (the arity cache survives).reifywith several clauses of the same method name (IReduce's 2- and 3-argreduce) kept only the last; they now form one multi-arity fn as indeftype.reduce,intoandtransducehonor a reify/deftype's ownreduce; the C-side deftype method dispatch (count/nth/get/…) also finds methods on reify instances.- GC: the tree-walker's
loopdid not root itsrecursentinel across the rebinding allocations; an optimizing build could keep only the pointer into the sentinel's heap-spilled argument array (invisible to the conservative scan) and the sweep freed it mid-iteration (ASan-O1heap-use-after-free;-O0hid it). Nowvolatile-kept likeapply's. - Macros expand under the namespace their call site was written in. cljc expands lazily (first call of the enclosing fn), so a macro that reads
*ns*or(ns-aliases *ns*)inside a library fn first called fromusersawuser— JVM Clojure expands at definition time.*ns*is restored afterwards, including when the expansion throws. - Windows CI: three
spit/load-filecalls used absolute POSIX paths, which a mingw build resolves against the current drive root — the suite aborted there. The portable one now uses a relative path (and cleans up after itself); the two that need a POSIX shell and the unix binary name arewhen-unixgated. - The nREPL test's read loop is bounded by a deadline.
csp/recv!parks on POLLIN with no timeout, so a server fiber dying without closing the socket wedged the whole suite at 100% CPU — and any./install.shrunning it.
v0.3.0 — 2026-08-27
Added
- Agents:
agent/send/send-off/send-via,await/await-for,agent-error/restart-agent(:clear-actions; a failed agent holds its queue), error modes (:faildefault,:continuewith:error-handler),release-pending-sends,shutdown-agents. Actions run serially per agent on a drain fiber (needs coroutines at runtime, likefuture). - STM: real
ref/dosync/alter/ref-set/commute/ensurereplacing the atom-backed shims — MVCC-lite with per-ref versions: commit verifies written andensured refs and retries the body on conflict (possible when a transaction yields — sleep/deref/io — while another fiber commits);commutere-applies on the latest value without conflicting; sends inside a transaction are held until commit;io!throws inside a transaction; nesteddosyncjoins the outer transaction.alter/ref-set/commute/ensureoutsidedosyncnow throw (they used to silently mutate). The current transaction is fiber-local (keyed by the fiber, not a dynamic var — abindingwould leak across yields to other fibers). - Watches:
add-watch/remove-watchare real on agents and refs (fired per action / at commit); still no-ops on atoms. cljc bundleentrypoint parity withjolt build -m/bb -m: the bundled binary now calls the script's-mainwith the command-line args when one is defined (top-level forms still run first; scripts without-mainare unchanged), and an integer return from-mainbecomes the exit status. Newcljc bundle -m <ns> <out>bundles a namespace from*load-path*(plus its transitive requires) and entrypoints its-main.cljc -mnow honors the integer-exit rule too.- **
cljc bundle --library**: build a script into a shared library (.so/.dylib/.dll) with a C ABI instead of an executable —cljc_lib_init()runs the embedded script,cljc_lib_eval(src)returnspr-strof the last form (NULLon error, interpreter survives),cljc_lib_last_error()reports; a matching<out>.hheader is generated for C/C++/Rust/Zig hosts (seeexamples/libhost.c).
- **
cljc vendorunderstands deps.edn**:cljc vendorwith no argument (or a deps.edn path) resolves the:depsmap transitively without Java or~/.m2— pinned:mvn/versionjars from Clojars/Maven Central followed through their poms (compile/runtime scope, property-versioned/optional/org.clojure/clojureskipped, Java-only jars tolerated),:git/url+:git/sha/:git/tagclones (URL inferred forio.github.*/com.github.*names,:deps/rootrespected),:local/rootcopies; git/local deps recurse into their own deps.edn. First coordinate wins; everything lands in./vendor/and gets the usual try-each-namespace load report. Maven mirrors (incl.file://) via thecljc/vendor-repos*atom.
- Conformance corpus:
fuzz/conformance.txt— 412 curated clojure.core expressions whose printed value must match JVM Clojure exactly, diffed against a golden file bymake conformance(part ofmake test; verified live against babashka whenbbis on PATH). Already caught and fixed five divergences:(partition-all n step coll), 3-collinterleave,.toUpperCase/.toLowerCase,rationalize(was identity; now derives exact ratios from the shortest decimal repr), andrangeelement types (start/step promotion decides element type, a double end only bounds —(range 2.5)is(0 1 2),(range 0 2 0.5)starts with int0).
- JIT type hints + doubles: the reader now keeps
^Taghints as{:tag Tag}metadata on symbols (read-time, like Clojure; still discarded on non-symbol forms), andjit.cljhonors JVM-style^long/^doublehints on params and the fn name — hinted functions compile to unboxeddouble/long longmachine ops, with the return type inferred from the body when unhinted. Also new in the JIT subset:/(double division),Math/sqrt,(double x)/(long x)casts, unary minus.
Fixed
compareon vectors orders by count first, then element-wise, like Clojure ((compare [2] [1 10])is -1;(sort [[2] [1 10] [1 2]])puts[2]first)- STM:
commuteon a ref alsoaltered/ref-setin the same transaction was applied twice at commit ((dosync (alter r inc) (commute r inc))on(ref 0)gave 3, not 2, and fired watches twice); commutes now skip refs in the write set, as in Clojure. cljc bundlerejected every flagbundle.cljdocuments (--static,--windows,--cc=,--libs=,--cflags=) — the C dispatcher demanded exactly two arguments, so cross-compilation was unreachable from the CLI; a bundled script that threw still exited 0 (now 1)- Memory safety: int-array bounds checks truncated 64-bit indices to 32 bits (
(aget a 4294967296)and(get a 4294967296)read/wrote out of bounds — segfault or heap corruption); printing bigints over ~280 digits overflowed a heap buffer ((str (apply *' (range 1 201)))); a source file ending right after^crashed the reader (NULL deref) bindingnow installs in parallel like Clojure (inits can't see earlier new values) and no longer permanently leaks earlier bindings when a later init throws or a var fails to resolve- Deftype method dispatch for
.length/.charAt/.subSequence/.closewas dead — acljc/dt-methodredefinition had swapped its parameters, so e.g.with-opennever called a deftype's ownclose; unified into one definition readingcljc/deftype-methods record?no longer returns true for deftype instances (consults thecljc/record-typesregistry the rest of the file already maintains)- Regex:
(?i)/(?m)/(?u)now raise "unsupported flag" instead of being silently stripped (wrong matches); a nested regex inside astr/replacereplacement fn no longer clobbers the outer pattern's(?s)flag;str/split/replace/replace-firstonly treat a string as a pattern when it carries the:regexmeta tag, not any metadata read-lineno longer silently splits lines longer than 4095 bytes- Conformance corpus:
(- 1/2 1/2)was in the golden as0while JVM Clojure gives0N(ratio→integer collapse yields a BigInt; cljc's small-N demotion is a documented divergence, so the case is replaced by(- 3/2 1/4)); the babashka re-verification inmake conformancenow fails on disagreement instead of being swallowed (keyword nil "a")returns:a(was an error), matchingsymbol(int-array 4294967296)errors instead of silently making a 0-length array- Under-arity native calls (
(atom),(mod 5),(subseq ss), …) raise "wrong number of args" instead of reading stale stack slots .containsworks on theHashSet./HashMap.atom shims again (a later string-oriented redefinition had dropped the atom branch)cljc watch:$/in file names no longer shell-expand- bundle: non-ASCII scripts embed as UTF-8 bytes, not codepoints (bundled binaries with unicode literals were corrupted); new
cljc/str-bytes*/cljc/str-nbytes*natives expose byte-level string access - http.clj:
Content-Lengthis now a byte count (non-ASCII bodies were mis-framed/truncated on both server and client side) - json.clj:
\uXXXXescapes decode fully, including surrogate pairs (were always replaced with?) - jit.clj:
(- x)compiled to identity (dropped the negation); chained comparisons(< a b c)compiled to C'st1 < t2 < t3instead of pairwise-AND csp/mergewith no input channels closes its output immediately- Removed nine dead duplicate prelude definitions (later defn silently won); fixed
process/sh,process/shell,fs/list-dir,http/serve,http/run-server,json/parsedocstrings that described behavior the code doesn't have
v0.2.0 — 2026-07-07
First tagged release. Everything below shipped since the version string was minted; highlights only — PLAN.md carries the engineering log.
Language & runtime
- Bytecode VM for hot functions and top-level forms, proper tail calls, primitive int64 arrays (
int-array/aget/aset), chunked lazyrange/repeat/repeatedly, realclojure.data.priority-map - Codepoint-indexed strings (UTF-8 storage, O(1) ASCII fast path) and a codepoint-aware regex engine — unicode class ranges, astral chars are one char (documented divergence from JVM UTF-16). Differentially fuzzed against babashka: 1,200 string/regex expressions, zero divergences
- Fiber-backed concurrency:
future/promise/Thread/sleep+clojure.core.async(go/<!/>!/alts!) on one cooperative scheduler - JVM interop shims: epoch-backed
java.time.Instantwith real ISO-8601,DateTimeFormatter/ISO_INSTANT, v4random-uuid, boxed-number methods, class keys for(extend Cls …)— vendoredclojure.data.jsonruns unmodified
Errors & debugging
- Uncaught errors show the source line with a caret, a
file:linetrace that reaches inside compiled fn bodies, typo suggestions, and — when an unresolved symbol's namespace exists on the load path — the actual fix:try (require 'clojure.set) first - Type errors name the value (
expected a number, got a keyword: :kw); stack overflows suggestloop/recur (dbg expr)/#p exprprint#dbg file.clj:12 (* x x) => 49and pass the value through;trace-vars/untrace-varsgive depth-indented call traces;*eholds the last REPL error and(pst)reprints it
REPL
(doc x)with arglists (natives and special forms included),(source x),(dir ns),apropos, tab completion, Ctrl-R history search, paren-depth continuation indent, pretty-printed wide results, numbered results (*1 *2 *3,(*results* n))- Ctrl-C interrupts evaluation instead of killing the session; printing an infinite seq shows
(0 1 … 99 ...)instead of hanging - Windows gets the full line editor (Windows 10+ VT console; plain-line fallback)
Tooling
cljc vendor <coord>/(vendor! "user/repo")— fetch a pure-Clojure library from Clojars (release jar) or GitHub into./vendor/and report which namespaces loadcljc watch file.clj(rerun on save),cljc doctor(load-path triage),cljc nreplwith nREPL-0.8completions/lookup(+ CIDER aliases), notebook mode,cljc bundle(script + runtime → one native binary)
Platforms
- CI-tested on Linux (+ ASan/UBSan), macOS arm64 + x86_64 (Rosetta), and Windows (mingw-w64); the suite runs twice everywhere (normal + GC stress)
- Tagged releases ship portable archives per platform: unpack anywhere and run — batteries and vendored libraries resolve relative to the binary