Skip to content

iho/phi

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Phi

A Haskell-like functional language that compiles directly to Erlang BEAM bytecode. Written in Rust.

What It Is

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!"

Features

  • Hindley-Milner type inference with type class constraints
  • Pattern matching — constructors, literals, tuples, list [x|xs] patterns, guards
  • Type classesclass/instance/where, dictionary passing, multi-parameter classes with functional dependencies
  • Do-notation and monadic >>= / >> / pure
  • List comprehensions[f x | x <- xs, pred x]
  • receive/after syntax for Erlang message passing
  • Where-clauses and local let bindings (including multi-clause function-style)
  • Lambda lifting with free-variable capture (make_fun3)
  • Foreign Function Interfaceforeign import maps to companion .erl modules
  • 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

Architecture

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.

Project Structure

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

Building

Requirements: Rust 1.85+ (edition 2024), Erlang/OTP 25+

cargo build --release

Running

Compile all stdlib and test modules to ebin/:

cargo run
# or: cargo run -- src/stdlib src/tests

Expected 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
ok

Note: 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 ebin

Then, in the Erlang shell, run:

'Phi.Test':main().

Notes:

  • src/tests/Test.phi is the main test entrypoint and is emitted as ebin/Phi.Test.beam.
  • Companion Erlang FFI modules are also compiled into ebin/ as Phi.*.FFI.beam during cargo run.
  • If you only want to rebuild generated BEAMs before rerunning tests, rerun cargo run -- src/stdlib src/tests and restart erl -pa ebin.
  • The current README documents a known limitation: Phi.Test:main/0 runtime constructor dispatch is not yet fully complete.

Language Reference

Types

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 }

Type Classes

class Eq a where
  eq :: a -> a -> Boolean

instance Eq Integer where
  eq x y = x == y

Pattern Matching

head :: [a] -> a
head (x:_) = x

zip :: [a] -> [b] -> [(a, b)]
zip []     _      = []
zip _      []     = []
zip (x:xs) (y:ys) = (x, y) : zip xs ys

Do-notation

main :: IO ()
main = do
  line <- readLine
  println line

Receive

loop :: IO ()
loop = do
  msg <- receive
    { "stop" -> pure ()
    ; x      -> do println x; loop
    } after 5000 -> pure ()

Foreign Import

foreign import length "erlang" "length" :: forall a. [a] -> Integer

Backed by a companion Phi.Module.FFI.erl file for non-trivial FFI.

Message Passing Concurrency — Ping/Pong

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 GenServer — Counter

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.

BEAM Codegen Notes

The BEAM emitter (beam_writer.rs) directly constructs binary .beam chunks:

  • Code chunk: instructions (allocate, move, call, call_ext, call_fun, put_list, put_tuple2, …)
  • Atom/AtU8 chunk: intern table
  • ImpT chunk: external call table
  • ExpT chunk: export table
  • FunT chunk: lambda table (for make_fun3)
  • LocT chunk: 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.

Known Limitations

  • Typechecker is permissive (prototype mode): unification failures are silently accepted to maximise compilation success across the full stdlib
  • Phi.Test:main/0 runtime: constructor dispatch for TestGroup not yet complete
  • No incremental compilation — full rebuild on every cargo run
  • No source maps or line-number information in generated BEAM files

About

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors