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.
module HelloWorld (main) where
import System.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 -pa ebin
1> F = 'Phi.HelloWorld':main(), F().
Hello World from Phi!
ok
2> F = 'Phi.PingPong':main(), F().
Ping!
Pong!
ok
3> F = 'Phi.Demo.Counter':main(), F().
Call: Query
22
okNote: 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.
data List a = Nil | Cons a (List a)
data Either a b = Left a | Right b
type Name = String
newtype Wrapper a = Wrap { unwrap :: a }class Eq a where
eq :: a -> a -> Boolean
instance Eq Integer where
eq x y = x == yhead :: [a] -> a
head (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 ()foreign import length "erlang" "length" :: forall a. [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.
module PingPong (main) where
import Prelude (IO, Process, Unit, bind, discard, getSelf, pure, spawn, (!))
import System.IO (println)
main :: IO ()
main = do
self <- getSelf
pid <- spawn loop
pid ! (self, :ping)
_ <- receive :pong -> println "Pong!"
pid ! :stop
where
loop :: 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.phi
module Demo.Server
( start
, inc
, dec
, query
) where
import Prelude (Integer, Atom, pure, ($))
import Control.Behaviour.GenServer
( class GenServer
, HandleCall, HandleCast, Init
, startLinkWith, initOk
, call, cast, noReply, reply, shutdown
)
import Control.Process (Process)
import Data.Pid (Pid)
import System.IO (println)
data Request = Inc | Dec | Query
data Reply = QueryResult Integer
data State = State Integer
name :: Atom
name = :server
start :: Process Pid
start = startServer
inc :: Process ()
inc = cast name Inc
dec :: Process ()
dec = cast name Dec
query :: Process Integer
query = do
QueryResult i <- call name Query
pure i
instance GenServer Request Reply State where
handleCall = handleCall
handleCast = handleCast
init :: Integer -> Init Request State
init n = initOk (State n)
handleCall :: HandleCall Request Reply State
handleCall Query _from (State i) = do
println "Call: Query"
reply (QueryResult i) (State i)
handleCall _req _from st = shutdown :badRequest st
handleCast :: HandleCast Request Reply State
handleCast Inc (State n) = noReply $ State (n + 1)
handleCast Dec (State n) = noReply $ State (n - 1)
handleCast _ st = noReply st-- src/stdlib/Demo/Counter.phi
module Demo.Counter (main) where
import Prelude (IO, bind, discard, pure)
import System.IO (println)
import Data.Show (show)
import Demo.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