Release: 0.48 - Step Into
0.48.0 - Step Into
Released 2026-07-15 · GitHub release
New core fns (trampoline, reductions, subvec, with-open, dbg) + step debugger + HTML coverage + read-only fixes & compiler speedups
🎉 Added
reduce-kv: reduce an associative collection with(f acc key value); vector indices act as keys,reducedshort-circuits (#2680)gcdandlcm: greatest common divisor and least common multiple of integers (#2686)trampoline: stack-safe mutual recursion by bouncing returned thunks (#2682)reductions: lazy sequence of a reduce's intermediate values, with or without init (#2681)subvec: bounds-checked persistent vector slice; non-integer bounds truncate toward zero andNaNcounts as0, matching Clojure (#2683)arityandvariadic?: reflect a function's required parameter count and variadic flag (#2685)inspect: pretty-prints a value (colored on TTY) and returns it unchanged, for pipeline debugging (#2688)with-open: scoped-cleanup macro; closes bound resources in reverse order, even on exception (#2684)phel test --coverage=html: self-contained HTML coverage report with line-colored.phelsources (#2692)phel exportstubs now carry native parameter/return types and@param/@returndocblocks from:tagmetadata (#2695)dbg: debug macro printing[file:line] form => valueto stderr, returning the value (#2687)(break): stepping-debugger foundation, pauses at the call site and opens a sub-REPL over the captured locals;(continue)or stdin EOF resumes, so non-interactive runs never hang (#2690)
🐛 Fixed
phelno longer fatals in read-only / unwritable environments (e.g. the NixOS build sandbox);--help,doc,eval,run,compile, and the REPL all work from an unwritable project root (nixpkgs 0.47.0versionCheckHookfailure):- caches degrade quietly instead of aborting, Gacela caches (via Gacela 1.16), build/intermediate caches, and the temp-dir/error-log writers skip writes without raw PHP warnings; a pre-warmed cache is still read
- the OPcache re-exec skips a read-only file-cache dir instead of letting PHP abort at startup
phel docinside a PHAR falls back to the system temp dir when the working directory is unwritable- CLI commands fail loudly, not silently:
phel formatskips an unreadable directory instead of aborting;phel format,phel test --coverage, andphel profile --outputreport a clear error when a target file can't be written;phel run --debugwarns once and keeps running on an unwritable log (it looped silent failed writes before)
symfony/polyfill-mbstringis now an explicit dependency, so minimal PHP builds withoutext-mbstringkeep working- Distributed PHAR no longer bundles example templates' build artifacts,
phel init --templateshipped any leftover.phel/cache andvendor/, bloating a release PHAR from ~2 MB to ~14 MB; only template sources ship now (#2678) phel test --helpnow shows--parallel=autoin its example; the bare--parallelit printed before errors, since the option needs a value (an integer,auto, ormax) (#2679)geton a:tag-typed vector with an out-of-range or non-integer key now returnsnil(or the supplied default), matching runtimeget, instead of throwing, the compiler no longer lowers it to an unguarded$v->get()(#2712)- The compiled-code cache no longer serves stale PHP after a compiler-only change: its per-source key hashes just the
.phelsource, so an emitter/analyzer change that alters output for unchanged source (e.g. the cross-fn return-type:taginference) could keep serving a pre-change compiled file within the same version. The cache index format version is bumped, invalidating stale entries once and forcing a cold recompile (#2732) - Phel no longer triggers PHP 8.3+ deprecation warnings,
ReflectionTypestring casts now call__toString()(removed in PHP 9.0), keepingphel export, LSP, and PHP-interop reflection warning-free on PHP 8.3–8.5
Performance
(reduce f init v)wherevis a local tagged as aPersistentVectorInterfacenow compiles to a nativeforeachover the vector with the step function hoisted out of the loop (resolved once, invoked directly per element) and the accumulator held in a plain PHP variable. The runtimephel.core/reduceboxes the accumulator and an early-exit flag in twoVolatileobjects and walks the collection with a genericdofor, paying aderef/resetpair plus areduced?dispatch on every element; the lowering removes all of it. The(reduced x)early-termination contract is preserved, aReducedresult unwraps into the accumulator and breaks the loop. The 2-arity(reduce f coll)is untouched (it seeds from the collection and calls(f)on empty input), as aremapandfilter, which are lazy and must stay lazy (#2714)(** x 2)wherexis a local taggedintorfloatnow compiles to a native($x * $x)instead of aNumericOperations::powercall, ~2.3x faster on PHP 8.5, since squaring is the common exponent and the runtime path is a real function call. Only the integer literal exponent2on a tagged local reduces: an untagged base could be aRatio/BigDecimalat runtime (whosepowerandmultiplypaths are not interchangeable), a float2.0exponent takespower's float branch, and the base is emitted twice so it must be a plain variable. The lowering emits the same native*a tagged(* x y)already does, so a tagged square inherits that operator's overflow behavior (overflows to float rather than promoting toBigInt); untagged**is unchanged (#2721)- A
foreach(and thedofor/doseqmacros built on it) in return position, a loop as a function's tail, now emits the loop as plain statements followed byreturn null;instead of wrapping it in an immediately-invoked closure(function() use (…) { … })(), saving a closure allocation and call frame on every call to that function; the tracing JIT benefits most. Expression-context loops keep the wrapper (they must yield a value inline), and a loop whose body containsphp/yieldkeeps it too, so the enclosing function is never silently promoted to a generator (#2716) let/loopbindings whose init calls a pure PHP built-in with a fixed primitive return type (php/cos,php/sqrt,php/ord,php/implode, and ~35 more trig/math/string/ctype_*functions) now infer that type, so arithmetic, comparison, and string ops on the binding emit native PHP operators instead of runtime dispatch, matching what already happened when the call was written inline as an operand. Argument-typed functions (pow,str_replace,json_encode) stay excluded so no wrong tag is stamped (#2712)def/defnlocation metadata emits a single\Phel::locationMeta(...)call instead of an expanded keyword-keyed map; extra metadata (:doc,:private,:tag, …) folds in as trailing args, so docstring-heavy namespaces shrink too. Runtime map stays value-equal (#2722, #2725)- Equal collection literals (
[1 2 3],{:a 1}, sets) repeated in a function body share one cached constant slot instead of one per occurrence, shrinking emitted PHP (#2720) - A
fn/defnwhose body returns a typed local straight through, a bare tagged param ((fn [^int x] x)) or alet/loopbinding of one, now declares that PHP return type on the emitted signature (nullable, union, and class types included) and propagates it to callers, giving opcache's tracing JIT more to specialize on. Purely additive: bare-literal bodies stay untyped, aloopbinding rebound through value-promoting arithmetic (+',*') stays untyped so aBigIntresult never trips a narrower return type, and no runtime behavior changes (#2718) - A
let/loopbinding whose init calls a Phel function with a primitive return:tag, aphel.corehelper or a userdefn, the tag either author-declared or inferred, now carries that type, so(let [s (make-greeting x)] (str s s))lowersstroversto a native concat and(let [n (tally x)] (+ n 5))to a native add, instead of runtime dispatch. Only the four primitive tags propagate (a nullable/union/class return never feeds a native scalar op), and a binding calling its own enclosingdefnis skipped so a redefinition never reads a stale registry tag (#2712)
👥 Contributors
Full Changelog: v0.47.0...v0.48.0
Downloads
v0.48.0
- phel.phar (2.03 MB)