Release: 0.54 - Road to One
0.54.0 - Road to One
Released 2026-10-04 · GitHub release
Inline int math up to 8.8x faster + protocols on PHP interfaces + 16 new core fns + sharper errors and lint JSON
🎉 Added
Language:
defmultitakes Clojure's signature,(defmulti name docstring? attr-map? dispatch-fn & options), with:defaultfor the fallback dispatch value and:hierarchyfor an atom or var holding a hierarchy. An unknown option fails with the supported list. (#3415)- A protocol extended to a PHP interface or parent class applies to every class that implements or extends it:
(extend-type \Countable Sized ...)covers\SplObjectStorage. The exact class wins, then the nearest parent, then an interface, then:default.\ArrayObject, a:usedArrayObjectand"ArrayObject"register the same key; a symbol used to register under the current namespace and never dispatch. (#3413) reifyimplements PHP interfaces, alone or next to protocols:(reify \JsonSerializable (jsonSerialize [this] 5)). It used to fail with a PHP parse error. Methods ofreify,defstructanddefenumdeclare the interface's return type, so PHP no longer printsDeprecated: Return type ... should either be compatible. (#3412)phel.corehaswith-out-str,subs,list*,qualified-keyword?,qualified-symbol?,hash,comparator,load-string,pcalls,pvalues,inst?,halt-when,ensure-reduced,partitionv,partitionv-allandreplace, as in Clojure. Coresubsthrows on a nil index;phel.string/subskeeps reading nil as the bounds. (#3417 #3418 #3437)map-indexed,partition-byandpartition-allreturn a transducer when called without a collection:(into [] (partition-by odd?) [1 3 2 4 5])returns[[1 3] [2 4] [5]]. (#3437)
Tooling:
PHEL_OPTIMIZATION_LEVELsets the optimization level for every command of a process, overphel-config.php.phel build -Ostill wins for its build. (#3396)- A file whose first form is not
(ns ...)fails with'src/app.phel' does not start with an (ns ...) form, instead of aCannot resolve symbol 'defn-'hint. A directory scan, such as the onephel evalruns over the working directory, skips such a file. (#3373 #3484 #3515) - Lint rule
phel/shadowed-core-fnwarns when alet,looporfnbinding is named after a publicphel.corefn, as in(let [inc (fn [x] 99)] (inc 1)). (#3374) - Editor completion after
(.methodknows a receiver built with(new Foo),(Foo.)or(Foo/make), bound or inline.(new Focompletes class names, and(DateTimeImmutable/completes static members without a:use. (#3398) phel doc reduce-kvprints the full doc ofreduce-kv: signatures, docstring, example and see-also. A search with no exact name, such asphel doc mapp, says so before the table of candidates, and the table no longer wraps a function name mid-word. (#3474)find-docin the REPL prints the doc of every public definition whose name or docstring matches a string or regex, as in Clojure:(find-doc "partition")listspartition,partition-allandpartition-by. (#3473)phel doc --format=jsonnames the namespace to require:upper-casecarries"requireNs": "phel.string"and"require": "(:require phel.string :refer [upper-case])". Aphel.corefn carriesrequireNsand a nullrequire. (#3466)phel lint --format=jsonandphel analyzereport one error with the same fields. Each diagnostic addserrorCode(PHEL001behindphel/unresolved-symbol),suggestions(the "did you mean" names as a list) andfix(the catalog's advice).phel analyze srcwalks a directory and exits 1 on an error.phel explain phel/unused-requireexplains a lint rule, and--format=jsonprints any entry as JSON. Adocs/path in a fix names the file from the project,vendor/phel-lang/phel-lang/docs/.... (#3464)
PHP API:
- BREAKING (PHP API, implementers only):
CompilerFacadeInterface::withoutDeprecations()runs a callable with deprecation notices held back. (#3381) - BREAKING (PHP API, implementers only):
CompilerFacadeInterface::rejectSupersededForms()throws on a superseded form in a form the reader returned. (#3456) - BREAKING (PHP API, implementers only):
CompilerFacadeInterface::findSimilarNames()returns the analyzer's "did you mean" candidates for a name. (#3454) - Public PHP API:
Phel\Lang\Destructure(withkwargs(),lookupSource()andrestKwargs()),\Phel::fnSlot(),Phel\Lang\ForeignFn,Phel\Shared\OptimizationLevel,Phel\Shared\SourceMap\SupersededSourceMaps, theMissingNsFormException/MissingNsFormHintpair andClassNotFoundHintinPhel\Shared\Exceptions, andLintRuleCodes::UNKNOWN_CLASS,UNRESOLVED_NAMESPACEandUNRESOLVED_REFER. (#3354 #3356 #3373 #3396 #3435 #3454 #3455 #3465 #3479 #3487)
⚡ Performance
Compiler:
- A multi-arity fn called as a value (passed to
reduceormap, or called as(f x)on a local) takes its arguments as fixed params instead of packing them into an array:(reduce + 0 v)is 7% faster,(f acc x)on a local bound to+18%. (#3469) - A build calls a multi-arity fn's fixed arity directly, also when the fn has a variadic arity:
(+ acc x)in a loop is 1.6x faster. Nested calls such as(-> m (get :a {}) (get :b {}))grow linearly instead of doubling per level. (#3354) - Comparisons,
=,zero?,pos?,neg?,incanddecon values of unknown type answer inline for native ints:(< a b)7.9x faster,(= x 3)8.8x. (#3351) caseandcondover keywords look each keyword up once per fn: a five-arm keywordcaseis 10x faster. (#3360)- A multi-arity fn built at runtime, such as what
comporpartialreturn, allocates one closure:(comp f g)created and called is 2.3x faster, with peak memory down from 125MB to 20MB. (#3355) - Sequential destructuring reads a vector by index:
(let [[a b] v] ...)is 3.2x faster. (#3356) (not x)compiles to an inline nil/false check, 2.6x faster; anifover a^boolparam skips the truthiness check. (#3352 #3353)- A namespace loaded from the compiled-code cache registers its definitions with one call per file instead of a guarded block per
def: loading a file of 500defs takes 1.33 ms instead of 2.89 ms, and a warmphel runpeaks 0.3 MB lower. The cache key hashes the source withxxh128instead ofmd5, 26x faster; existing caches rebuild once. (#3470) - Binary
+,-and*on values of unknown type answer inline for native ints:(+ a b)is 4x faster,(* a b)6x. An overflow still promotes toBigInt, and floats,BigIntand nil take the runtime fn as before. (#3468)
CLI:
phelrestarts PHP for the opcache file cache only where it pays off: never for--version,list,helporcompletion, and on macOS only fortest,build,bench,mutate,profileandexport.phel --versionon macOS drops from 125 ms to 87 ms.PHEL_OPCACHE_REEXEC=1restarts for every command. (#3425)- Commands that do not print the version start no
gitprocess, saving about 20 ms each. (#3407) - A warm
phel runorphel testno longer walks Phel's own PHP sources to validate the namespace scan cache: a one-filephel rundrops from 110 ms to 103 ms. (#3467)
🐛 Fixed
Compiler:
- BREAKING: a map or set literal that repeats a constant key fails with
[PHEL203] Duplicate key: :a.{:a 1 :a 2}used to read as{:a 2}. (#3387) - BREAKING: the argument count check (
PHEL002) rejects too many arguments, as it does too few, and covers namespaces with a-and fns defined earlier in the same file underphel compile.(defn sq [n] (* n n)) (sq 3 4)used to run.^:dynamicand^:redeffns stay unchecked. (#3384 #3394) - BREAKING: map destructuring reads namespaced keys as Clojure does:
{:my/keys [a]}and{:keys [my/a]}read:my/a,{::keys [a]}reads::a.{:my/keys [a]}used to read:a; write{:keys [a]}for that. (#3414) - BREAKING: a
:referof a name the required namespace does not define fails at thensform:(:require app.util :refer [f nope])reports[PHEL013] 'nope' is referred from app.util, which does not define it., and a private name fails the same way. Drop the name from:refer, or define it. Lint rulephel/unresolved-referreports it. It used to surface only whennopewas called, or never.phel.testdefinesthrown?,thrown-with-msg?andoutput?so(:require phel.test :refer [is thrown?])keeps working; outsideisthey fail with a message namingis. (#3455) - A
^voidfn compiles and returns nil. It used to emitreturn <value>in a PHP: voidfunction. (#3363) - A fn whose body contains
php/yieldcompiles. It used to get its tail's return type, which PHP rejects on a generator. (#3365) - A call to a
^:dynamicor^:redeffn in a build seesbinding. It used to keep the first value it saw, and-O2inlined short^:dynamicfns pastbinding. (#3367) (def- x "doc" 1)bindsxto1with"doc"as its docstring. It used to bindxto"doc". (#3372)(let [a false] (if a a a))returnsfalse. It used to returnnilwith anUndefined variablewarning when the other branch read the local again. (#3383)- A symbol that does not resolve gets suggestions from where it says it lives.
(str/join "," [1 2])with no require fails withNo namespace or alias 'str'. Did you mean (:require phel.string :as str)?,(s/uper-case "a")suggestss/upper-case, and a bareupper-casenames(:require phel.string :refer [upper-case]). A qualified symbol used to get unrelated core names, or no suggestion at all. (#3458) #instaccepts every prefix of the full timestamp, as Clojure does:#inst "2026-03-04". An out-of-range field such as2026-02-30is still rejected. (#3416)
Runtime:
- BREAKING:
transducecalls the reducing fn's completion once, as Clojure does: a reducer with a 1-arity finalizes the result. A reducer with only a 2-arity now throwsArgumentCountError; wrap it incompleting. (#3433) - Map destructuring reads a seq as keyword arguments and anything else through
get, as Clojure does:(let [{:keys [a]} '(:a 1)] a)and the same over(map identity [:a 1])return1, and(let [{:keys [a]} #{:a}] a)returns:a. A set or a lazy seq used to fail withCannot use object ... as array. A map keeps its inline lookup. (#3479) seqandnextkeep a lazy seq or list whose next element is nil, as in Clojure:(next (map identity [1 nil 3]))returns(nil 3). They used to return nil and drop the rest, so(reduce + (map :price items))stopped silently at the first missing key. (#3451)- Map destructuring of a vector or list returns nil for a key that is not an index, as
getdoes:(let [{:keys [a]} [1 2]] a)returns nil. It used to print a notice and throw aTypeError.(contains? [1 2] :a)returnsfalse; it used to returntrue. (#3475) (seq 5)fails withDon't know how to create a seq from: int. It used to blameapply, which the user never called. (#3459)- A runtime arity error names the Phel fn:
[PHEL401] Wrong number of args (1) passed to app.main/add, expected 2. It used to print PHP'sToo few arguments to function Phel\Lang\AbstractFn@anonymous::__invoke()with the path of the PHP file that made the call. (#3459) countcounts the seqnextreturns for a lazy seq:(count (next (map identity [1 2 3])))returns2. It used to throwcount is not supported on a lazily consumed source. (#3452)declarekeeps the value of a symbol that is already defined, as in Clojure. Loading a namespace again used to reset what it declares to nil, so a long-lived process (REPL,phel watch, nREPL) could fail withValue of type null is not callablewhile recompilingphel.core. (#3444)partition-allthrows for a size or step that is not a positive int. A size of 0 used to return an empty seq, and a step of 0 never ended. (#3437)- The
dedupetransducer keeps a leading:phel/none. It used to drop it. (#3437) with-output-buffercloses its buffer when the body throws, and restores the buffer level it found. It used to leave the buffer open. (#3417)formatandprintfconvert a ratio, bigint or bigdec:(format "%.2f" (/ 250 100))returns"2.50". It used to print"1.00"with a warning. (#3386)(gensym "tmp")returnstmp42-style names, as in Clojure. It used to ignore the prefix.(break)hides the localsgensym,x#and#(...)create, and lists every local you bind. (#3385)- A map pattern after
&reads the rest arguments as keyword arguments, as Clojure does:((fn [& {:keys [a]}] a) :a 1)and((fn [& {:keys [a]}] a) {:a 1})return1, and a trailing map merges over the pairs. It used to look the keys up in the rest vector and fail. (#3487)
Tooling:
phel --versionreports Phel's own commit. Inside another git repository it used to print that repository's commit. (#3407)- An edit to
phel-config-<env>.phptakes effect on the next run. It used to be ignored untilphel cache:clear. (#3426) - A stack trace maps every frame to its
.phelsource after an edit to a file loaded with(load ...), and after a recompile in the REPL,phel watchor nREPL. Frames used to show.phel/cache/compiled/paths or the new code's lines. (#3430 #3435) - The PHAR's precompiled stdlib names its sources inside the PHAR. It used to carry the release builder's absolute paths, so a deprecation raised inside the stdlib reached the user from the PHAR while a source checkout hid it. (#3448)
phel testin parallel mode reads worker stderr as it arrives. On macOS a worker writing over 16 KB to stderr used to crawl. (#3378)phel runprints a backslash separator notice once per loaded file, with a relative path. It used to warn for every.phelfile under the working directory, on every warm run. (#3381)phel mutatecompiles at optimization level 0 whatever the config says. At level 2 every mutant used to survive. (#3396)phel doc --format jsonand the API reference link special forms to their current guide sections, and:see-alsoentries name real fns. (#3371)build/preload.phppreloads every module's pillars, found at startup. The hand-kept list missed eight modules and named three deleted files. (#3410)- A top-level
(require phel.string :as s)in a file fails withrequire is only available in the REPL; use (:require ...) inside ns. It used to suggestDid you mean 'reduce'?. (#3476) phel initwrites acomposer.jsonrequiring the running Phel, such as"phel-lang/phel-lang": "^0.54.0", so thecomposer installstep it prints works. An existingcomposer.jsonis kept, even with--force. (#3472)- A failing
(is (= expected actual))printsexpected:andactual:, and its diff names the path of each change inside nested maps and vectors:~ [:b :c 2] 3 -> 4,+ [:b :e] 5,- [:x] 1. The diff used to repeat the whole subtree under the top-level key. A failing(is (not (= a b)))now saysbut is: = toinstead ofbut is not: = to. (#3471) phel evalandphel runprint values and errors without colour codes when stdout is not a terminal, as inphel eval '#{1 2}' | cat.--ansiforces colour;--no-ansiandNO_COLORnow remove it from printed values too. (#3462)phel lint,phel analyze, the LSP and the api-daemon report(php/new \DateTime),php/->,php/::andset-varasPHEL012, asphel rundoes. They used to report the file as clean. (#3456)phel lintre-lints every file once after a Phel upgrade. A file cached as clean used to stay clean when the new release reports something more, until it changed or--no-cachewas passed. (#3478)- A require of a
phel.*orclojure.*namespace that Phel does not ship fails at thensform:(:require phel.strng)reportsCannot find namespace 'phel.strng' required by 'app.main'. Did you mean 'phel.string'?, and lint rulephel/unresolved-namespaceflags it. It used to load nothing and fail later withCannot resolve symbol.clojure.setresolves tophel.core, so(set/union #{1} #{2})returns#{1 2}. (#3454) - A file whose
nsform does not analyse no longer breaks every command.phel lintreports it as a diagnostic (PHEL007 :refer :all is not supported..., in JSON with--format=json) and lints the other files,phel runof a file that does not require it works, andphel buildfails naming the file and line. All three used to stop with a bareRefer must be a vectorand no file. (#3457) (Integer/parseInt "3"),(Math/abs -1),(System/currentTimeMillis),(Thread/sleep 1)and(String/valueOf 1)end with a hint naming the Phel replacement:hint: Integer is a Java class, not a PHP one: for Integer/parseInt use (parse-long s).(.toUpperCase "abc")points atphel.stringinstead of leavingClass "abc" not foundalone. Lint rulephel/unknown-classwarns about a static call to a class it cannot autoload. (#3465)- A require of a missing namespace reports
[PHEL014]with the requiring file, line and a caret on the namespace, plus the directories searched. It used to point at Phel's ownExtractorException.php.phel explain PHEL014describes it. (#3461) - Errors and stack frames name a Phel var as
ns/name:Wrong number of arguments to function "bugs.arity/add",(bugs.rt2/step [1 2]),(phel.core/* 2 nil). They used to printbugs.arity\addandbugs\rt2\step, which raise the backslash deprecation when pasted back into code. (#3460) - REPL and nREPL completion insert a qualified name as
phel.string/upper-case. It used to insertphel.string\upper-case, which prints the backslash separator deprecation. A prefix typed with\still matches. (#3500) phel initwrites(defn greet [who] ...), so a fresh project passesphel lint. It used to warn thatnameshadows a core fn. The docsphel agent-installcopies teach a check loop (phel lint --format=json,phel explain,phel doc --format=json), point atvendor/phel-lang/phel-lang/src/phel/instead of asrc/phel/core/a project does not have, list the Clojure habits that do not work in Phel, and no longer append phel-lang's own commit rules. (#3463)- Two
phelprocesses that compile the same code at the same time, asphel test --parallelworkers do, no longer see a defined fn as nil. They shared one temp file per compiled form, so one could read the other's empty or half-written file and fail withValue of type null is not callable. (#3495)
👥 Contributors
Full Changelog: v0.53.0...v0.54.0
Downloads
v0.54.0
- phel.phar (2.78 MB)