Release: 0.50 - The Last Zero
0.50.0 - The Last Zero
Released 2026-08-14 · GitHub release
Clojure-style PHP interop + core/compiler speedups up to 60x + LSP local jumps + new phel bench and phel balance
🎉 Added
- Add LSP go-to-definition for lexical locals: a usage jumps to the symbol that bound it in
let,loop,fn,foreach,catch,if-let,when-let,if-some,when-someorwhen-first, honouring shadowing, destructuring and progressive binding scope. A name rebound by a form whose shape is not modelled (defn,for,dofor,binding) resolves to nothing rather than to an outer binding of the same name (#3131) - Add
phel balance [paths]... [--fix]: reports unbalanced(),[]and{}, and with--fixappends the missing closers. Scans the lexer's token stream, so\(, a(inside a string, a;comment or a#"regex"is never counted. Detection is the default, and it refuses anything with more than one plausible fix: a surplus or mismatched closer, an unterminated string, a file that will not lex, a trailing reader prefix or lone\, or a missing closer followed by a new top-level form, which is read from a column-0 opener, a reader prefix that started at column 0, or a definition head at any indent (#2827 #3164) - Add
phel.benchand thephel benchcommand:defbenchbenchmarks written in Phel, with baseline storage (--store), comparison (--ref) and a tolerance gate (--tolerance) (#3005) - Add Clojure-style PHP interop for value members, dynamic calls, enum cases and
set!, with editor completion and hover, including static properties (#2881 #2883 #2887 #2888 #2907 #2916) - Add editor completion and hover for PHP superglobals (
php/$_SERVER, ...), in the LSP and in the REPL/nREPL (#3037) - Add
ProjectIndex::$namespaceLocationsandProjectIndex::namespaceLocation(): thensdeclaration site of each indexed namespace, keyed by its canonical dotted name.phel index --outputgains a matchingnamespaceLocationskey (#3059) - Add
Phel::installExceptionHandler($projectRootDir)for source-mapped uncaught throwable reports (#2922) - Add
.nrepl-portdiscovery and graceful shutdown tophel nrepl(#2894) - Add
BuildFacade::enterDependencyLoad(),leaveDependencyLoad()andisLoadingDependencies(), plusBuildConstants::LOADING_DEPENDENCIES. Additive: they mark the region in which annsform loads its requires, so the emitter can decline to pin call sites there (#3015) - Add
Phel\Lang\Seq::reductions()andPhel\Lang\Seq::flatten(), the generators backingphel.core/reductionsandphel.core/flatten(#2973) - Add public
Phel\Shared\NoColorhelpers for embedder output styling (#2923) - Allow keyword keys in
#phpmap literals (#2952) - Add call-site warnings for definitions marked with
:deprecated - Add lint rules
phel/comment-styleandphel/duplicate-def - Add
phel.test/with-isolated-statsandwith-isolated-reporters - Add
phel agent-install --checkfor installed.agents/drift checks, and a heredoc example tophel eval -h - Add language surface, stability, migration and ADR documentation with drift gates, plus public PHP/core API snapshots,
:docchecks and@internalenforcement - Add macOS support, coverage, benchmark and mutation gates to CI
🐛 Fixed
- LSP rename no longer corrupts source.
SymbolExtractorwrote 0-based columns intoLocationandDefinition, whose contract is 1-based, so every range landed one column left and renamingaddin(defn add [x] x)produced(defnsubd [x] x). The definition site was also edited twice. Go-to-definition on adef, document and workspace symbols and find-references shift with it, as do the columns ofphel index --outputand theapi-daemonresponses, which serialize without the LSP conversion (#3157) Phel\Lang\Collections\Exceptions\IndexOutOfBoundsExceptionextends SPL'sOutOfBoundsException.phel.core/nthraises\OutOfBoundsExceptionfrom its own body, but anthon a statically typed collection lowers to->get(), which raises the former; the two shared no ancestor butException, so(catch \OutOfBoundsException e ...)caught the call or not depending on whether the binding happened to carry a tag. Additive (#3042)- Stop the
-O2call inliner relocating an argument that contains a PHP interop call, which may write through a by-reference parameter that the inlined closure then captures by value:phel.http/uri-from-stringsilently mis-parsed an IPv6 URI. Stop it splicing away a callee whose parameters carry a:tag, which dropped both the emitted parameter type and the native-arithmetic lowering it enables (#3126) phel.htmlrenders araw-stringholding a non-string with Phel's spelling again. The runtime render joins withphp/implode, which casts with PHP's rules, so(raw-string true)came out as1and(raw-string false)as the empty string. Only the runtime path was affected, not a literal folded by thehtmlmacro (#3099)- Print a lazy sequence as
(1 2)rather than@[1 2].@is the deref reader macro, so the old form did not round-trip. MatchesCons,PersistentListand Clojure (#3113) empty?no longer reports a lazy sequence whose first element isnilas empty, the emptiness check no longer realizes the whole sequence (an unbounded source terminates, and taking 5 of a 200,000 elementlazy-seqdrops from 21ms to 0.004ms), and alazy-seqover a chunked sequence no longer yields only its first element whilecountreported the true length (#3020 #3023 #3025)- Three architecture tests located Gacela pillars with a glob rooted at the module directory, while Gacela 2.0 resolves them by filename suffix at any depth; all three now walk recursively (#3062)
rector.phpselects its PHPUnit rule set withPHPUnitSetList::COMPOSER_BASEDinstead of the pinnedPHPUNIT_100, which is absent from somerector-phpunitbuilds and abortedcomposer test-qualitywith a fatalUndefined constant. A unit test now asserts every set constant named inrector.phpexists (#3133)- Drop
log2from the compiler'sphp/*return-type table; PHP has no such function, so the entry described a call that could never succeed (#2712) - Document
~and~@as the unquote and unquote-splicing shorthands inphel doc, not,and,@. Since #2827,is whitespace, so the documented example evaluated to something other than what the same entry claimed - Report the pattern by name when
phel.string/splitis given something PCRE rejects, instead of lettingpreg_splitreturn false intoPhel/vectorand surfacing aTypeErrorabout a bool phel.string/blank?no longer reports invalid UTF-8 as blank,trim-newlineno longer rewrites invalid UTF-8 bytes to?, and(join ", " nil)returns""rather than the separator (#3050 #3052)- Fix literal matching in
phel.string/replaceandreplace-first(#2957) - Stop
phel lintaborting the whole run with an uncaughtTypeErroron a namespace that both defines and calls an:inlinefunction (#3055) - Go-to-definition on a namespace inside a
(:require ...)form now jumps to that namespace'snsdeclaration, and LSP definition, hover, find-references and rename read a name spelled with the deprecated\separator as one word instead of splitting at the backslash (#3059) with-redefsis no longer ignored afterphel evalhas loaded a required namespace. Loading a dependency runs under build mode, which also let the emitter pin global call sites intostaticslots, and the pinned artifact was written to the ordinary cache for laterphel testruns to reuse (#3015)phel docand the API reference report every arity of adefover anfn; 23 symbols published no signature at all,defnanddefmacroamong them, whilelist,vectorandhash-mappublished a stale hardcoded one (#3012)- Stop the scan-index and namespace caches growing without bound on every
phel doc, LSP hover and REPL completion (#3007) - Stop internal test, nREPL and watch eval forms from emitting namespace separator deprecations (#2950)
- Validate
slurppaths correctly while preserving stream-wrapper support, and evaluateasetvalue expressions once (#2941 #2943) - Resolve
defexceptionparent class spellings,(:use ...)aliases and dotted class names in:tagmetadata consistently (#2924 #2936) - Stop
phel docwarnings from generatedphel-internal.dochelpers, and teach dot namespace spelling inphel docforin-ns,nsanduse(#2936) - Honor
NO_COLORin exception and REPL error formatting (#2923) - Reject
$members outside static-property positions, and compile static property assignment and class-name instance-property places (#2907 #2915) - Prefer all-caps PHP classes over same-named constants in host symbol resolution
- Generate dot namespaces from
phel init, examples, templates and.agents/recipes, and teach the preferred dotted and markerless spellings across active docs and agent guidance (#2827 #2949) - Isolate nREPL current namespaces per session and report them in
nsframes (#2894 #2906) - Prefer local definitions over same-named
:referimports and warn on redefinition (#2897) - Fail unresolved
(:require ...)at require time, support project requires in eval/nREPL, and allow symbol redefinition there while keeping file compilation strict (#2886 #2891 #2896) - Avoid re-evaluating kebab-case namespaces in REPL, nREPL and eval
- Use Phel truthiness in
filter,take-whileanddrop-while - Preserve metadata through
persistent!andassoc/conjspecialization - Support eduction in
first,empty?andreduce; keepcountguidance explicit - Fix empty iterable
seq, invalidlazy-seqerrors, transient sorted-set invocation andbool|intsorted comparators - Stabilize multimethod parameter rendering and generated table docstrings
- Make
phel.repl/compile-strcompile without evaluating - Report syntax deprecations under
--warn-deprecationswithout corrupting generated PHP - Keep
phel lintrunning across project requires, report crashing rules as internal errors, improve handling for loop heads, multi-arity params, dot aliases and deprecated vars, and fail unreadable lint config, source files and directories loudly - Preserve PHP interpreter flags through OPcache re-exec
- Fix CLI exit codes for format, ns, doc and config commands
- Skip the
inotifywatch backend wheninotifywaitis missing - Report failing
data-readers.phelbootstrap files with their cause - Improve circular dependency, atom validator and chained compiler diagnostics
- Add and verify public core
:exampleand:see-alsometadata - Amend stale ADRs and document accepted ADR corrections (#2827 #2876)
- Document measured classes loaded by built applications
⚠️ Deprecated
- Deprecate source use of
php/new,php/->andphp/::; prefer Clojure-style interop (#2877) - Deprecate
set-var; usealter-var-rootfor roots andset!for dynamic binding frames (#2888)
⚖️ Changed
- BC The core functions listed under Performance declare real arities instead of taking a rest argument. Two consequences. A wrong argument count raises an arity error rather than being silently ignored or reaching a hand-written message, which affects
get,get-in,reduce,into,slice,rangeand the twelve seq functions. And a multi-arity function compiles to one variadic dispatch, soarityreports0for every one of them,get,assoc,dissoc,concat,swap!,swap-vals!,range,comp,repeat,repeatedly,==,merge-withanddeep-mergeincluded, as well asjuxt,interleaveandunion(#2960 #2962 #2964 #2975 #3065 #3067 #3069 #3077 #3083) - BC:
(max)and(min)with no arguments are now rejected at compile time (PHEL002) rather than throwingArgumentCountErrorat runtime, since both declare their arities (#2991) - BC: require
symfony/console^7.3|^8.0andgacela-project/gacela^2.0 - BC: expand
ApiFacadeInterfaceand addFormatterFacadeInterface::formatString - BC: move API transfer objects from
Phel\Api\TransfertoPhel\Shared\Api - BC: rename four
FileIoInterfacecontracts after their responsibilities - Change
Phel\Shared\Api\Definition, parser/reader contracts and several internal class locations - Warn for
\namespace separators without--warn-deprecations; other deprecations stay opt-in, and reports for code undervendor/are suppressed (#2827) - Accept the future PHP class spelling after
\: dotted or bare, with no leading marker. Warn when adefshadows a loadable PHP class;\DateTimestill reaches the class (#2876) - Lower
(or …)/(and …)test chains containingphp/instanceof,php/agetor a quoted literal to native||/&&instead of an IIFE;(get <set> k)is ~24% faster (#3027) - Declaring
boolon the comparison operators lets the compiler infer more return types than before: adefnwhose body is(= x 5)now emits: bool, and one branching on(= x nil)to two int literals now emits: int. More type information, but visible in generated output (#2979) - Sync
.agents/incrementally and track installed files in.agents/.phel-agent-manifest.json(#2809) - Adopt explicit Gacela 2 service maps and key facade dependencies by consumer-facing interface
- Route compiler deprecations through one switch, message factory and dedup table
Performance
Compiler:
- Type a
let/loopbinding and a function's return from a collection literal or avector/list/hash-map/array-mapcall, so the collection specialisations fire on ordinary code rather than only hand-annotated sources: 4.07x on a vector, 2.40x on a map, 3.33x when the collection comes back from a call (#3042) - Apply every single-use lowering to any typed target, not only a bare variable:
get,empty?,assoc/conj/dissoc, the vector and seq accessors,reduce,get-in, the named accessors, the numeric predicates and thephp/arrayand string gates.get-in2.79x,nth2.42x,reduce1.93x,get1.78x.last,secondandcontains?still need a bare variable (#3042) - Carry an inferred tag through a
letrebinding, into a nestedlet, from anif/do/nested-letinit, and from a class orarrayreturn type (#2985 #2987 #3021 #3040) - Carry a declared return tag to call sites, so
(str "<" (escape-html s) ">")lowers to native concatenation: 1.62x on a three-part concatenation, and 26 runtimestrcalls leave the standard library. A^intreturn now takes the native arithmetic path, which overflows to float rather than promoting toBigInt(#2712) - Widen the
php/*return-type table from 76 entries to 125 across the string, bool and int sides:(str "[" (php/mb_substr s 0 4) "]")1.83x, andis_agives everydefstructpredicate aboolreturn (#2712) - Skip the
Truthyadapter in anifwhose test is a known PHP bool, and lower(not x)over one to a native!(3.7x). Covers the comparison operators, anydefn ^bool, fifteen core predicates and the string interop wrappers: 1 to 7% per test (#2712 #2973 #3021) - Compile a literal
(list ...),(vector ...),(queue ...),(hash-map ...)or(array-map ...)straight to itsPhelfactory: 2.2 to 3.6x (#3014) Runtime core: - Replace rest-argument dispatch with real arities across
phel.core: the arithmetic and comparison operators,str, the collection constructors and accessors, the seq and transducer functions, and the nil-rejecting arithmetic guards. 1.03x to 25x by how much work sits behind the call,roundandfloor25x,strat four and five arguments 3.9x and 4.1x (#2941 #2962 #2964 #2974 #2975 #2978 #2979 #2981 #2989 #2991 #3010 #3017 #3018 #3021 #3065 #3067 #3069) - Give every closure-returning combinator real arities:
partial30x,fnil50x,constantly9.5x,comp8x,juxt4.3 to 5.8x,complement4.4x,some-fn2.3x,every-pred2.2x, plus every transducer's reducing function,completingandtransduce(withmap4.8x, withfilter3.8x) (#2973 #3021 #3026 #3083) - BREAKING
sort-bynow calls its key function once per element instead of twice per comparison, so an impure key function runs far fewer times (100 rather than about 1114 when sorting 100 elements). The sorted result is unchanged. Up to 2.2 times faster; recorded indocs/spec/clojure-divergences.mdsince Clojure calls per comparison (#3006) - Sort natively rather than calling back into Phel per comparison, when the keys are all native ints or all native strings and no comparator was given:
sorton strings 22.7x,sort-byon strings 13.7x, on ints 8.4x,(sort-by count strings)5.2x. Ties keep input order (#2973 #3003 #3004 #3079) - Compare two native ints without the numeric tower in
<,<=,>,>=,minandmax(2.5 to 3.0x), answerzero?/pos?/neg?with a direct PHP comparison (4.2x) andeven?/odd?with one modulo (5.9x) (#2973 #2984) - Add a PHP array of ints in
sumwitharray_sum: 27.5x over 200 ints, 17.5x over 20, carryingmean13.9x.sumstill promotes an overflowing total to BigInt;meandivides withphp/fdivand has never accepted one (#3085) - Seed
union's result from its first argument instead of re-adding its elements:(union big small)63x,(union s)51x, two 20-element sets 3.0x (#3077) - Build collections into a PHP array and construct once instead of appending per element:
vec3.1x to 3.5x,intoa vector 3.5x,php->phel1.97x,kvs1.65x, a two-mapmerge2.6x (#2973) - Write transient accumulators with
.put/.add/.find/.containsinstead ofget,assoc!andassoc:invert2.04x,group-by1.50x,select-keys1.26x,set1.19x,zipmap1.15x,frequencies1.11x. Thedistincttransducer tracks what it has seen in a transient set: 1.6x (#2973) - Walk with
foreachrather thanfor ... :pairs:stringify-keys2.45x,keywordize-keys2.4x,postwalk2.29x,merge-with2.16x,map-invert1.99x,update-valsandupdate-keys1.9x,rename-keys1.76x (#2973) - Step a generator-backed lazy sequence with one
Consper element instead of twoLazySeqwrappers:concatrealized 1.62x,maprealized 1.55x. Realize 4 elements at construction instead of 32, later chunks still 32:partition-by4.8x,dedupe3.5x, at 6-7% for a sequence consumed in full (#2973 #3061) - Build
reductionsandflattenfrom PHP generators instead of recursing in Phel and walking an eagertree-seq:reductionsrealized 2.11x and taken 2.32x,flattenconstructed 38.0μs to 0.63μs,(take 3 ...)47.6μs to 7.5μs, 64 leaves 120.5μs to 69.3μs. Both stay lazy (#2973) - Reach the common branch first instead of a chain of predicates in
get(1.6 to 1.8x,get-in1.2x),assoc(1.24x),dissoc(1.14x),conj,int,empty?andseq, and giveassoc!a fixed arity (1.74x) (#2960 #2961 #2963 #2965 #2973 #3071) - Stop
all?andsome?re-testingempty?once per element:every?over a list 2.1x,not-any?1.5x, over a vector 1.4x (#3074) - Walk
tree-seq's stack with native PHP operations and push children by index: 2.1x (#3081) - Collect
forresults into a PHP array instead of an atom and a persistentconjper element, and thread the:reduceaccumulator through a one-slot PHP array (#2997 #2999) - Build
to-php-array's result withSeqInterface::toArrayinstead of spreading throughapply: 3.3x at three elements, 8.7x at a hundred, carryingphel.string/join2.2x (#3057) - Test
reduced?and the loop guard inreduceandreduce-kvas the PHP checks they are:reduce1.2x, andtransducewithmapa further 1.1x on top of #3101 (#2973) - Inline PHP array constructors and use
php-indexed-array,agetandasetacross core and standard libraries, speed upre-find,re-matchesandparse-longby avoiding full match-map conversion, and buildinterleave's seq array in one pass (#2941 #2942 #2943 #2980) - Walk the entries a hash-map node holds when it splits into an
ArrayNodeinstead of probing all 32 slots: 1.06x (#2973) Standard library: - Rewrite the hot half of
phel.string:assert-stringbecomes:inline, seven functions take fixed arities,trim-newlineusesrtrim,blank?settles ASCII withstrspnfirst,contains?stops running its guards twice, andsplitbuilds its vector directly. 1.6x to 8.4x across fourteen functions (#3050 #3051 #3052) - Render a
phel.htmlelement without normalizing it first, tag its string helpers^string, join the runtime render withimplode, and hoistescape-html's flag constant (4.4x). A document renders 1.43x, 1.38x and 1.51x faster across the three, and[:span {:class "x"} "0"]goes from 17.7μs to 7.0μs (#2973 #3054 #3098) - Build every
phel.walkbranch through a transient instead ofmapplusinto: 1.67x to 1.71x (#3087) - Swap
phel.base64/encode-urlanddecode-url's alphabet with onestrtrinstead of chainedreplacecalls: 28x and 17x. All three take fixed arities, makingdecode5.2x (#3021) - Escape JUnit XML attributes with one
htmlspecialcharspass instead of five chainedreplacecalls: 25x. Invalid UTF-8 now becomes U+FFFD rather than being emitted raw, which was not valid XML (#3094) - Give
phel.json/encodeanddecodefixed arities in place of& [{:flags .. :depth ..}]: 3.9x encoding a scalar, 1.25x encoding a map, 1.21x decoding (#3090) - Stop
phel.pprintprinting every node twice and allocating a printer per node: 1.60x on a deep structure, 1.48x on a small one (#3092) - Derive each
[:map ...]entry's shape once inphel.schema/validateinstead of twice: 1.23x on a four-field map, scaling with field count (#3096) Packaging: - Keep the PHAR within its release size budget by excluding dependency-only Gacela tooling, the release-announcement scripts and
infection.json5(#2994)
🗑️ Removed
- BC: remove deprecated reader syntax
#| |#, bare#,|(...)andfoo$(#2827) - BC: remove
^:reference; use^:by-ref(#2827) - BC: remove long-deprecated core aliases; see the migration guide (#2784)
- BC: remove CLI aliases
phel index --outandphel config --json(#2827) - BC: remove legacy REPL history migration and
PHEL_QUIET_MIGRATION - BC: remove unused
ExceptionPrinterInterfaceprint methods - Remove unused facade test-support methods and unused internal helpers
👥 Contributors
@chemaclass @jasalt @JesusValeraDev
Full Changelog: v0.49.0...v0.50.0
Downloads
v0.50.0
- phel.phar (2.37 MB)