A Haskell-like functional language that compiles directly to Erlang BEAM bytecode. Written in Rust.
Phi is a statically typed, purely functional language with Haskell-style syntax — type classes, do-notation, list comprehensions, pattern matching, guards, where-clauses — that targets the Erlang VM without going through Erlang source. The compiler reads .phi source files and writes .beam files directly.
moduleHelloWorld (main) whereimportSystem.IO (println)
main::IO()
main = println "Hello World from Phi!"- Hindley-Milner type inference with type class constraints
- Pattern matching — constructors, literals, tuples, list
[x|xs]patterns, guards - Type classes —
class/instance/where, dictionary passing, multi-parameter classes with functional dependencies - Do-notation and monadic
>>=/>>/pure - List comprehensions —
[f x | x <- xs, pred x] receive/aftersyntax for Erlang message passing- Where-clauses and local
letbindings (including multi-clause function-style) - Lambda lifting with free-variable capture (
make_fun3) - Foreign Function Interface —
foreign importmaps to companion.erlmodules - QuickCheck-style property-based testing
- 194 stdlib modules — Data.List, Data.Map, Data.Array, Control.Monad, System.IO, Text.Json, Text.Parsec, Test.QuickCheck, and more
The compiler is a single Rust binary with four sequential passes:
.phi files
│
▼
[Pass 1] Lexer (logos)
│ Token stream
▼
[Pass 2] Layout resolver
│ Inserts { } ; for indentation-sensitive syntax
▼
[Pass 3] Parser (chumsky)
│ AST: Module { declarations: Vec<Decl> }
▼
[Pass 4] Typechecker
│ Hindley-Milner + type class constraint solving
│ Builds global Env (bindings, instances, aliases)
▼
[Pass 5] BEAM codegen (beam_writer)
│ Emits BEAM bytecode directly (no erlc involved)
▼
ebin/*.beam
Passes 1–3 run in parallel via rayon. Pass 4 is sequential (shared Env). Pass 5 runs in parallel; companion .erl (FFI) files are compiled by erlc concurrently in a background thread.
src/
ast.rs — AST types: Decl, Expr, Binder, Type, ...
lexer.rs — Logos-based tokenizer
layout.rs — Haskell layout rule (indentation → braces)
parser.rs — Chumsky parser → AST
type_sys.rs — MonoType / PolyType definitions
env.rs — Type environment (bindings, classes, instances)
typechecker.rs — HM inference + type class constraint solving
beam_writer.rs — Direct BEAM bytecode emitter
main.rs — Driver: orchestrates all passes
stdlib/ — 180+ standard library .phi modules + FFI .erl files
tests/ — ~58 test .phi modules
Requirements: Rust 1.85+ (edition 2024), Erlang/OTP 25+
cargo build --releaseCompile all stdlib and test modules to ebin/:
cargo run
# or: cargo run -- src/stdlib src/testsExpected output:
--- Batch Parsing ---
Summary: 194/194 files parsed successfully.
--- Typechecking ---
Typechecking done: 194/194 ok
--- Codegen (.phi → .beam directly) ---
Direct .beam: 194 | failed: 0
Run the compiled modules in the Erlang REPL:
$ erl-paebin1>F='Phi.HelloWorld':main(), F().
HelloWorldfromPhi!ok2>F='Phi.PingPong':main(), F().
Ping!Pong!ok3>F='Phi.Demo.Counter':main(), F().
Call: Query22okNote: IO actions are lazy — main/0 returns a fun; calling it executes the effect.
Run the generated test BEAMs:
# compile stdlib + test modules into ebin/
cargo run -- src/stdlib src/tests
# start Erlang with generated beams on the code path
erl -pa ebinThen, in the Erlang shell, run:
'Phi.Test':main().Notes:
src/tests/Test.phiis the main test entrypoint and is emitted asebin/Phi.Test.beam.- Companion Erlang FFI modules are also compiled into
ebin/asPhi.*.FFI.beamduringcargo run. - If you only want to rebuild generated BEAMs before rerunning tests, rerun
cargo run -- src/stdlib src/testsand restarterl -pa ebin. - The current README documents a known limitation:
Phi.Test:main/0runtime constructor dispatch is not yet fully complete.
dataLista=Nil | Consa (Lista)
dataEitherab=Lefta | RightbtypeName=StringnewtypeWrappera=Wrap{unwrap::a}classEqawhereeq::a->a->BooleaninstanceEqIntegerwhere
eq x y = x == yhead:: [a] ->ahead (x:_) = x
zip:: [a] -> [b] -> [(a, b)]
zip[] _ =[]zip _ []=[]zip (x:xs) (y:ys) = (x, y) :zip xs ysmain::IO()
main =do
line <- readLine
println lineloop::IO()
loop =do
msg <- receive
{ "stop"->pure()
; x ->do println x; loop
} after 5000->pure()foreignimportlength"erlang""length"::foralla. [a] ->IntegerBacked by a companion Phi.Module.FFI.erl file for non-trivial FFI.
Phi uses Erlang's actor model. Processes communicate via ! (send) and receive.
modulePingPong (main) whereimportPrelude (IO, Process, Unit, bind, discard, getSelf, pure, spawn, (!))
importSystem.IO (println)
main::IO()
main =do
self <- getSelf
pid <- spawn loop
pid ! (self, :ping)
_ <- receive :pong -> println "Pong!"
pid !:stop
whereloop::Process()
loop =
receive
(from, :ping) ->do
println "Ping!"
from !:pong
loop
:stop ->pure()Source lives in src/stdlib/PingPong.phi and compiles to ebin/Phi.PingPong.beam.
OTP behaviours are expressed via type classes. Demo.Server defines the GenServer with handleCall/handleCast callbacks. Demo.Counter is the entry point that drives it.
-- src/stdlib/Demo/Server.phimoduleDemo.Server
( start
, inc
, dec
, query
) whereimportPrelude (Integer, Atom, pure, ($))
importControl.Behaviour.GenServer
( classGenServer
, HandleCall, HandleCast, Init
, startLinkWith, initOk
, call, cast, noReply, reply, shutdown
)
importControl.Process (Process)
importData.Pid (Pid)
importSystem.IO (println)
dataRequest=Inc | Dec | QuerydataReply=QueryResultIntegerdataState=StateIntegername::Atom
name =:server
start::ProcessPid
start = startServer
inc::Process()
inc = cast name Incdec::Process()
dec = cast name Decquery::ProcessInteger
query =doQueryResult i <- call name Querypure i
instanceGenServerRequestReplyStatewhere
handleCall = handleCall
handleCast = handleCast
init::Integer->InitRequestStateinit n = initOk (State n)
handleCall::HandleCallRequestReplyState
handleCall Query _from (State i) =do
println "Call: Query"
reply (QueryResult i) (State i)
handleCall _req _from st = shutdown :badRequest st
handleCast::HandleCastRequestReplyState
handleCast Inc (State n) = noReply $State (n +1)
handleCast Dec (State n) = noReply $State (n -1)
handleCast _ st = noReply st-- src/stdlib/Demo/Counter.phimoduleDemo.Counter (main) whereimportPrelude (IO, bind, discard, pure)
importSystem.IO (println)
importData.Show (show)
importDemo.Server (start, inc, dec, query)
main::IO()
main =do
start
inc
inc
inc
dec
n <- query
println (show n)Run it:
cargo run -- src/stdlib src/tests
erl -pa ebin -noshell -eval "F = 'Phi.Demo.Counter':main(), F(), halt()"Expected output:
Call: Query
22
Source lives in src/stdlib/Demo/ and compiles to ebin/Phi.Demo.Server.beam and ebin/Phi.Demo.Counter.beam.
The BEAM emitter (beam_writer.rs) directly constructs binary .beam chunks:
Codechunk: instructions (allocate, move, call, call_ext, call_fun, put_list, put_tuple2, …)Atom/AtU8chunk: intern tableImpTchunk: external call tableExpTchunk: export tableFunTchunk: lambda table (formake_fun3)LocTchunk: local function table
Lambda lifting: free variables are captured at make_fun3 creation and passed as extra X-register arguments to the lifted function body.
Where-clauses with binders become lifted local functions. Zero-arity where bindings and PatBind where-clauses are inlined directly into the parent function body.
- Typechecker is permissive (prototype mode): unification failures are silently accepted to maximise compilation success across the full stdlib
Phi.Test:main/0runtime: constructor dispatch forTestGroupnot yet complete- No incremental compilation — full rebuild on every
cargo run - No source maps or line-number information in generated BEAM files