Skip to main content

Release: 0.50 - The Last Zero

Latest update

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-some or when-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 --fix appends 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.bench and the phel bench command: defbench benchmarks 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::$namespaceLocations and ProjectIndex::namespaceLocation(): the ns declaration site of each indexed namespace, keyed by its canonical dotted name. phel index --output gains a matching namespaceLocations key (#3059)
  • Add Phel::installExceptionHandler($projectRootDir) for source-mapped uncaught throwable reports (#2922)
  • Add .nrepl-port discovery and graceful shutdown to phel nrepl (#2894)
  • Add BuildFacade::enterDependencyLoad(), leaveDependencyLoad() and isLoadingDependencies(), plus BuildConstants::LOADING_DEPENDENCIES. Additive: they mark the region in which an ns form loads its requires, so the emitter can decline to pin call sites there (#3015)
  • Add Phel\Lang\Seq::reductions() and Phel\Lang\Seq::flatten(), the generators backing phel.core/reductions and phel.core/flatten (#2973)
  • Add public Phel\Shared\NoColor helpers for embedder output styling (#2923)
  • Allow keyword keys in #php map literals (#2952)
  • Add call-site warnings for definitions marked with :deprecated
  • Add lint rules phel/comment-style and phel/duplicate-def
  • Add phel.test/with-isolated-stats and with-isolated-reporters
  • Add phel agent-install --check for installed .agents/ drift checks, and a heredoc example to phel eval -h
  • Add language surface, stability, migration and ADR documentation with drift gates, plus public PHP/core API snapshots, :doc checks and @internal enforcement
  • Add macOS support, coverage, benchmark and mutation gates to CI

🐛 Fixed

  • LSP rename no longer corrupts source. SymbolExtractor wrote 0-based columns into Location and Definition, whose contract is 1-based, so every range landed one column left and renaming add in (defn add [x] x) produced (defnsubd [x] x). The definition site was also edited twice. Go-to-definition on a def, document and workspace symbols and find-references shift with it, as do the columns of phel index --output and the api-daemon responses, which serialize without the LSP conversion (#3157)
  • Phel\Lang\Collections\Exceptions\IndexOutOfBoundsException extends SPL's OutOfBoundsException. phel.core/nth raises \OutOfBoundsException from its own body, but a nth on a statically typed collection lowers to ->get(), which raises the former; the two shared no ancestor but Exception, so (catch \OutOfBoundsException e ...) caught the call or not depending on whether the binding happened to carry a tag. Additive (#3042)
  • Stop the -O2 call 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-string silently 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.html renders a raw-string holding a non-string with Phel's spelling again. The runtime render joins with php/implode, which casts with PHP's rules, so (raw-string true) came out as 1 and (raw-string false) as the empty string. Only the runtime path was affected, not a literal folded by the html macro (#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. Matches Cons, PersistentList and Clojure (#3113)
  • empty? no longer reports a lazy sequence whose first element is nil as empty, the emptiness check no longer realizes the whole sequence (an unbounded source terminates, and taking 5 of a 200,000 element lazy-seq drops from 21ms to 0.004ms), and a lazy-seq over a chunked sequence no longer yields only its first element while count reported 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.php selects its PHPUnit rule set with PHPUnitSetList::COMPOSER_BASED instead of the pinned PHPUNIT_100, which is absent from some rector-phpunit builds and aborted composer test-quality with a fatal Undefined constant. A unit test now asserts every set constant named in rector.php exists (#3133)
  • Drop log2 from the compiler's php/* 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 in phel 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/split is given something PCRE rejects, instead of letting preg_split return false into Phel/vector and surfacing a TypeError about a bool
  • phel.string/blank? no longer reports invalid UTF-8 as blank, trim-newline no longer rewrites invalid UTF-8 bytes to ?, and (join ", " nil) returns "" rather than the separator (#3050 #3052)
  • Fix literal matching in phel.string/replace and replace-first (#2957)
  • Stop phel lint aborting the whole run with an uncaught TypeError on a namespace that both defines and calls an :inline function (#3055)
  • Go-to-definition on a namespace inside a (:require ...) form now jumps to that namespace's ns declaration, 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-redefs is no longer ignored after phel eval has loaded a required namespace. Loading a dependency runs under build mode, which also let the emitter pin global call sites into static slots, and the pinned artifact was written to the ordinary cache for later phel test runs to reuse (#3015)
  • phel doc and the API reference report every arity of a def over an fn; 23 symbols published no signature at all, defn and defmacro among them, while list, vector and hash-map published 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 slurp paths correctly while preserving stream-wrapper support, and evaluate aset value expressions once (#2941 #2943)
  • Resolve defexception parent class spellings, (:use ...) aliases and dotted class names in :tag metadata consistently (#2924 #2936)
  • Stop phel doc warnings from generated phel-internal.doc helpers, and teach dot namespace spelling in phel doc for in-ns, ns and use (#2936)
  • Honor NO_COLOR in 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 ns frames (#2894 #2906)
  • Prefer local definitions over same-named :refer imports 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-while and drop-while
  • Preserve metadata through persistent! and assoc/conj specialization
  • Support eduction in first, empty? and reduce; keep count guidance explicit
  • Fix empty iterable seq, invalid lazy-seq errors, transient sorted-set invocation and bool|int sorted comparators
  • Stabilize multimethod parameter rendering and generated table docstrings
  • Make phel.repl/compile-str compile without evaluating
  • Report syntax deprecations under --warn-deprecations without corrupting generated PHP
  • Keep phel lint running 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 inotify watch backend when inotifywait is missing
  • Report failing data-readers.phel bootstrap files with their cause
  • Improve circular dependency, atom validator and chained compiler diagnostics
  • Add and verify public core :example and :see-also metadata
  • 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/-> and php/::; prefer Clojure-style interop (#2877)
  • Deprecate set-var; use alter-var-root for roots and set! 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, range and the twelve seq functions. And a multi-arity function compiles to one variadic dispatch, so arity reports 0 for every one of them, get, assoc, dissoc, concat, swap!, swap-vals!, range, comp, repeat, repeatedly, ==, merge-with and deep-merge included, as well as juxt, interleave and union (#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 throwing ArgumentCountError at runtime, since both declare their arities (#2991)
  • BC: require symfony/console ^7.3|^8.0 and gacela-project/gacela ^2.0
  • BC: expand ApiFacadeInterface and add FormatterFacadeInterface::formatString
  • BC: move API transfer objects from Phel\Api\Transfer to Phel\Shared\Api
  • BC: rename four FileIoInterface contracts 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 under vendor/ are suppressed (#2827)
  • Accept the future PHP class spelling after \: dotted or bare, with no leading marker. Warn when a def shadows a loadable PHP class; \DateTime still reaches the class (#2876)
  • Lower (or …) / (and …) test chains containing php/instanceof, php/aget or a quoted literal to native || / && instead of an IIFE; (get <set> k) is ~24% faster (#3027)
  • Declaring bool on the comparison operators lets the compiler infer more return types than before: a defn whose 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/loop binding and a function's return from a collection literal or a vector/list/hash-map/array-map call, 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 the php/array and string gates. get-in 2.79x, nth 2.42x, reduce 1.93x, get 1.78x. last, second and contains? still need a bare variable (#3042)
  • Carry an inferred tag through a let rebinding, into a nested let, from an if/do/nested-let init, and from a class or array return 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 runtime str calls leave the standard library. A ^int return now takes the native arithmetic path, which overflows to float rather than promoting to BigInt (#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, and is_a gives every defstruct predicate a bool return (#2712)
  • Skip the Truthy adapter in an if whose test is a known PHP bool, and lower (not x) over one to a native ! (3.7x). Covers the comparison operators, any defn ^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 its Phel factory: 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, round and floor 25x, str at 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: partial 30x, fnil 50x, constantly 9.5x, comp 8x, juxt 4.3 to 5.8x, complement 4.4x, some-fn 2.3x, every-pred 2.2x, plus every transducer's reducing function, completing and transduce (with map 4.8x, with filter 3.8x) (#2973 #3021 #3026 #3083)
  • BREAKING sort-by now 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 in docs/spec/clojure-divergences.md since 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: sort on strings 22.7x, sort-by on 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 <, <=, >, >=, min and max (2.5 to 3.0x), answer zero?/pos?/neg? with a direct PHP comparison (4.2x) and even?/odd? with one modulo (5.9x) (#2973 #2984)
  • Add a PHP array of ints in sum with array_sum: 27.5x over 200 ints, 17.5x over 20, carrying mean 13.9x. sum still promotes an overflowing total to BigInt; mean divides with php/fdiv and 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: vec 3.1x to 3.5x, into a vector 3.5x, php->phel 1.97x, kvs 1.65x, a two-map merge 2.6x (#2973)
  • Write transient accumulators with .put/.add/.find/.contains instead of get, assoc! and assoc: invert 2.04x, group-by 1.50x, select-keys 1.26x, set 1.19x, zipmap 1.15x, frequencies 1.11x. The distinct transducer tracks what it has seen in a transient set: 1.6x (#2973)
  • Walk with foreach rather than for ... :pairs: stringify-keys 2.45x, keywordize-keys 2.4x, postwalk 2.29x, merge-with 2.16x, map-invert 1.99x, update-vals and update-keys 1.9x, rename-keys 1.76x (#2973)
  • Step a generator-backed lazy sequence with one Cons per element instead of two LazySeq wrappers: concat realized 1.62x, map realized 1.55x. Realize 4 elements at construction instead of 32, later chunks still 32: partition-by 4.8x, dedupe 3.5x, at 6-7% for a sequence consumed in full (#2973 #3061)
  • Build reductions and flatten from PHP generators instead of recursing in Phel and walking an eager tree-seq: reductions realized 2.11x and taken 2.32x, flatten constructed 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-in 1.2x), assoc (1.24x), dissoc (1.14x), conj, int, empty? and seq, and give assoc! a fixed arity (1.74x) (#2960 #2961 #2963 #2965 #2973 #3071)
  • Stop all? and some? re-testing empty? 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 for results into a PHP array instead of an atom and a persistent conj per element, and thread the :reduce accumulator through a one-slot PHP array (#2997 #2999)
  • Build to-php-array's result with SeqInterface::toArray instead of spreading through apply: 3.3x at three elements, 8.7x at a hundred, carrying phel.string/join 2.2x (#3057)
  • Test reduced? and the loop guard in reduce and reduce-kv as the PHP checks they are: reduce 1.2x, and transduce with map a further 1.1x on top of #3101 (#2973)
  • Inline PHP array constructors and use php-indexed-array, aget and aset across core and standard libraries, speed up re-find, re-matches and parse-long by avoiding full match-map conversion, and build interleave's seq array in one pass (#2941 #2942 #2943 #2980)
  • Walk the entries a hash-map node holds when it splits into an ArrayNode instead of probing all 32 slots: 1.06x (#2973) Standard library:
  • Rewrite the hot half of phel.string: assert-string becomes :inline, seven functions take fixed arities, trim-newline uses rtrim, blank? settles ASCII with strspn first, contains? stops running its guards twice, and split builds its vector directly. 1.6x to 8.4x across fourteen functions (#3050 #3051 #3052)
  • Render a phel.html element without normalizing it first, tag its string helpers ^string, join the runtime render with implode, and hoist escape-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.walk branch through a transient instead of map plus into: 1.67x to 1.71x (#3087)
  • Swap phel.base64/encode-url and decode-url's alphabet with one strtr instead of chained replace calls: 28x and 17x. All three take fixed arities, making decode 5.2x (#3021)
  • Escape JUnit XML attributes with one htmlspecialchars pass instead of five chained replace calls: 25x. Invalid UTF-8 now becomes U+FFFD rather than being emitted raw, which was not valid XML (#3094)
  • Give phel.json/encode and decode fixed arities in place of & [{:flags .. :depth ..}]: 3.9x encoding a scalar, 1.25x encoding a map, 1.21x decoding (#3090)
  • Stop phel.pprint printing 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 in phel.schema/validate instead 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 #, |(...) and foo$ (#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 --out and phel config --json (#2827)
  • BC: remove legacy REPL history migration and PHEL_QUIET_MIGRATION
  • BC: remove unused ExceptionPrinterInterface print 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


View release on GitHub