+```
+
+### 10.2 Fractional Permissions
+
+```
+type File
+
+fn read : &(1/2) File -> Bytes
+fn write : &(1) File -> ()
+```
+
+### 10.3 Concurrency
+
+```
+type Chan : Linear
+
+fn send : (Chan, T) -> ()
+fn recv : Chan -> T
+```
+
+---
+
+## Appendix A: Grammar
+
+```ebnf
+module ::= decl*
+
+decl ::= fn_decl | type_decl
+
+fn_decl ::= 'fn' IDENT '(' params ')' ':' type '=' expr
+
+type_decl ::= 'type' IDENT '=' type
+
+params ::= (param (',' param)*)?
+param ::= IDENT ':' type
+
+type ::= base_type
+ | 'String' '@' IDENT
+ | type '->' type
+ | '(' type ',' type ')'
+ | type '+' type
+ | '&' type
+ | 'region' IDENT '{' type '}'
+
+base_type ::= '()' | 'Bool' | 'I32' | 'I64' | 'F32' | 'F64'
+
+expr ::= literal
+ | IDENT
+ | 'String.new' '@' IDENT '(' STRING ')'
+ | 'String.concat' '(' expr ',' expr ')'
+ | 'String.len' '(' expr ')'
+ | 'let' IDENT '=' expr 'in' expr
+ | 'let!' IDENT '=' expr 'in' expr
+ | 'fn' '(' IDENT ':' type ')' '->' expr
+ | expr '(' expr ')'
+ | '(' expr ',' expr ')'
+ | expr '.' ('0' | '1')
+ | 'inl' '[' type ']' '(' expr ')'
+ | 'inr' '[' type ']' '(' expr ')'
+ | 'case' expr 'of' 'inl' '(' IDENT ')' '->' expr
+ 'inr' '(' IDENT ')' '->' expr 'end'
+ | 'if' expr 'then' expr 'else' expr
+ | 'region' IDENT '{' expr '}'
+ | '&' expr
+ | 'drop' '(' expr ')'
+ | 'copy' '(' expr ')'
+
+literal ::= INTEGER | FLOAT | STRING | 'true' | 'false' | '()'
+```
+
+---
+
+## Appendix B: References
+
+1. Wadler, P. (1990). *Linear types can change the world!*
+2. Tofte, M. & Talpin, J.P. (1997). *Region-based memory management*
+3. Walker, D. (2005). *Substructural type systems* (ATTAPL Chapter 1)
+4. Bernardy, J.P. et al. (2018). *Linear Haskell: practical linearity in a higher-order polymorphic language*
+5. Grossman, D. et al. (2002). *Region-based memory management in Cyclone*
+
+---
+
+*End of Specification*
diff --git a/src/ephapax-runtime/Cargo.toml b/src/ephapax-runtime/Cargo.toml
new file mode 100644
index 0000000..2ea4389
--- /dev/null
+++ b/src/ephapax-runtime/Cargo.toml
@@ -0,0 +1,24 @@
+# SPDX-License-Identifier: EUPL-1.2
+# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+[package]
+name = "ephapax-runtime"
+description = "WebAssembly runtime support for the Ephapax language"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+keywords.workspace = true
+categories.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+# no_std compatible - no external deps for WASM target
+
+[features]
+default = ["std"]
+std = []
diff --git a/src/ephapax-runtime/src/lib.rs b/src/ephapax-runtime/src/lib.rs
new file mode 100644
index 0000000..c0329f0
--- /dev/null
+++ b/src/ephapax-runtime/src/lib.rs
@@ -0,0 +1,189 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! Ephapax WASM Runtime
+//!
+//! Low-level runtime support for Ephapax compiled to WebAssembly.
+//! This provides the memory management primitives used by generated code.
+
+#![cfg_attr(not(feature = "std"), no_std)]
+#![allow(clippy::missing_safety_doc)]
+
+// Panic handler for no_std environments (WASM target)
+#[cfg(all(not(feature = "std"), not(test), target_arch = "wasm32"))]
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+ // In WASM, we can't do much - just loop forever
+ loop {}
+}
+
+/// String handle: offset into linear memory
+pub type StringHandle = u32;
+
+/// Memory layout constants
+pub mod layout {
+ /// Offset of bump pointer
+ pub const BUMP_PTR: usize = 0;
+ /// Offset of region stack pointer
+ pub const REGION_SP: usize = 4;
+ /// Start of region stack
+ pub const REGION_STACK: usize = 8;
+ /// Maximum region stack depth
+ pub const MAX_REGION_DEPTH: usize = 64;
+ /// Start of heap
+ pub const HEAP_START: usize = REGION_STACK + MAX_REGION_DEPTH * 4;
+}
+
+/// Initialise runtime memory
+///
+/// # Safety
+///
+/// Must be called before any other runtime functions.
+/// Assumes linear memory is properly allocated.
+#[no_mangle]
+pub unsafe extern "C" fn __ephapax_init() {
+ // Set initial bump pointer
+ core::ptr::write(layout::BUMP_PTR as *mut u32, layout::HEAP_START as u32);
+ // Set initial region stack pointer (empty)
+ core::ptr::write(layout::REGION_SP as *mut u32, layout::REGION_STACK as u32);
+}
+
+/// Bump allocate `size` bytes, return pointer
+///
+/// # Safety
+///
+/// Assumes runtime has been initialised and sufficient memory is available.
+#[no_mangle]
+pub unsafe extern "C" fn __ephapax_bump_alloc(size: u32) -> u32 {
+ let bump_ptr = core::ptr::read(layout::BUMP_PTR as *const u32);
+ let new_ptr = bump_ptr + size;
+ core::ptr::write(layout::BUMP_PTR as *mut u32, new_ptr);
+ bump_ptr
+}
+
+/// Create a new string from data pointer and length
+/// Returns a handle (pointer to string header)
+///
+/// # Safety
+///
+/// `data_ptr` must point to valid memory of at least `len` bytes.
+#[no_mangle]
+pub unsafe extern "C" fn __ephapax_string_new(data_ptr: u32, len: u32) -> StringHandle {
+ // Allocate header (8 bytes: ptr + len)
+ let handle = __ephapax_bump_alloc(8);
+
+ // Store pointer
+ core::ptr::write(handle as *mut u32, data_ptr);
+ // Store length
+ core::ptr::write((handle + 4) as *mut u32, len);
+
+ handle
+}
+
+/// Get the length of a string
+///
+/// # Safety
+///
+/// `handle` must be a valid string handle.
+#[no_mangle]
+pub unsafe extern "C" fn __ephapax_string_len(handle: StringHandle) -> u32 {
+ core::ptr::read((handle + 4) as *const u32)
+}
+
+/// Get the data pointer of a string
+///
+/// # Safety
+///
+/// `handle` must be a valid string handle.
+#[no_mangle]
+pub unsafe extern "C" fn __ephapax_string_ptr(handle: StringHandle) -> u32 {
+ core::ptr::read(handle as *const u32)
+}
+
+/// Concatenate two strings, consuming both handles
+/// Returns a new handle
+///
+/// # Safety
+///
+/// Both handles must be valid string handles.
+#[no_mangle]
+pub unsafe extern "C" fn __ephapax_string_concat(h1: StringHandle, h2: StringHandle) -> StringHandle {
+ let ptr1 = core::ptr::read(h1 as *const u32);
+ let len1 = core::ptr::read((h1 + 4) as *const u32);
+ let ptr2 = core::ptr::read(h2 as *const u32);
+ let len2 = core::ptr::read((h2 + 4) as *const u32);
+
+ let new_len = len1 + len2;
+ let new_data = __ephapax_bump_alloc(new_len);
+
+ // Copy first string
+ core::ptr::copy_nonoverlapping(ptr1 as *const u8, new_data as *mut u8, len1 as usize);
+
+ // Copy second string
+ core::ptr::copy_nonoverlapping(ptr2 as *const u8, (new_data + len1) as *mut u8, len2 as usize);
+
+ // Create new handle
+ __ephapax_string_new(new_data, new_len)
+}
+
+/// Drop a string handle (no-op with regions, memory freed on region exit)
+#[no_mangle]
+pub extern "C" fn __ephapax_string_drop(_handle: StringHandle) {
+ // No-op: memory is managed by regions
+}
+
+/// Enter a new region: save current bump pointer
+///
+/// # Safety
+///
+/// Must not exceed maximum region depth.
+#[no_mangle]
+pub unsafe extern "C" fn __ephapax_region_enter() {
+ let region_sp = core::ptr::read(layout::REGION_SP as *const u32);
+ let bump_ptr = core::ptr::read(layout::BUMP_PTR as *const u32);
+
+ // Push bump_ptr onto region stack
+ core::ptr::write(region_sp as *mut u32, bump_ptr);
+
+ // Increment region stack pointer
+ core::ptr::write(layout::REGION_SP as *mut u32, region_sp + 4);
+}
+
+/// Exit region: restore bump pointer, freeing all region allocations
+///
+/// # Safety
+///
+/// Must have a matching `__ephapax_region_enter` call.
+#[no_mangle]
+pub unsafe extern "C" fn __ephapax_region_exit() {
+ let region_sp = core::ptr::read(layout::REGION_SP as *const u32);
+
+ // Decrement and read saved bump_ptr
+ let new_sp = region_sp - 4;
+ let saved_bump = core::ptr::read(new_sp as *const u32);
+
+ // Restore bump pointer (effectively frees all region allocations)
+ core::ptr::write(layout::BUMP_PTR as *mut u32, saved_bump);
+ core::ptr::write(layout::REGION_SP as *mut u32, new_sp);
+}
+
+/// Check if we're in any region
+///
+/// # Safety
+///
+/// Assumes runtime has been initialised.
+#[no_mangle]
+pub unsafe extern "C" fn __ephapax_in_region() -> u32 {
+ let region_sp = core::ptr::read(layout::REGION_SP as *const u32);
+ if region_sp > layout::REGION_STACK as u32 {
+ 1
+ } else {
+ 0
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ // Tests would run in native mode, not WASM
+ // Integration tests should use wasm-bindgen-test
+}
diff --git a/src/ephapax-syntax/Cargo.toml b/src/ephapax-syntax/Cargo.toml
new file mode 100644
index 0000000..6ed8d4a
--- /dev/null
+++ b/src/ephapax-syntax/Cargo.toml
@@ -0,0 +1,17 @@
+# SPDX-License-Identifier: EUPL-1.2
+# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+[package]
+name = "ephapax-syntax"
+description = "Abstract syntax tree and type definitions for the Ephapax language"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+keywords.workspace = true
+categories.workspace = true
+
+[dependencies]
+smol_str = { workspace = true }
diff --git a/src/ephapax-syntax/src/lib.rs b/src/ephapax-syntax/src/lib.rs
new file mode 100644
index 0000000..7d5ce3d
--- /dev/null
+++ b/src/ephapax-syntax/src/lib.rs
@@ -0,0 +1,287 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! Ephapax Abstract Syntax Tree
+//!
+//! Core syntax definitions aligned with the formal Coq semantics.
+
+use smol_str::SmolStr;
+
+/// Variable identifier
+pub type Var = SmolStr;
+
+/// Region name
+pub type RegionName = SmolStr;
+
+/// Source location span
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Span {
+ pub start: usize,
+ pub end: usize,
+}
+
+impl Span {
+ pub fn new(start: usize, end: usize) -> Self {
+ Self { start, end }
+ }
+
+ pub fn dummy() -> Self {
+ Self { start: 0, end: 0 }
+ }
+}
+
+/// Linearity annotation
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Linearity {
+ /// Must use exactly once
+ Linear,
+ /// May use any number of times
+ Unrestricted,
+}
+
+/// Base (primitive) types
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BaseTy {
+ Unit,
+ Bool,
+ I32,
+ I64,
+ F32,
+ F64,
+}
+
+/// Types with region and linearity annotations
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Ty {
+ /// Primitive type
+ Base(BaseTy),
+
+ /// String allocated in a region
+ String(RegionName),
+
+ /// Reference with linearity
+ Ref {
+ linearity: Linearity,
+ inner: Box,
+ },
+
+ /// Function type A -> B
+ Fun { param: Box, ret: Box },
+
+ /// Product type A * B
+ Prod { left: Box, right: Box },
+
+ /// Sum type A + B
+ Sum { left: Box, right: Box },
+
+ /// Region-scoped type
+ Region { name: RegionName, inner: Box },
+
+ /// Second-class borrow &T
+ Borrow(Box),
+
+ /// Type variable (for polymorphism, future)
+ Var(SmolStr),
+}
+
+impl Ty {
+ /// Check if type is linear (must be used exactly once)
+ pub fn is_linear(&self) -> bool {
+ match self {
+ Ty::String(_) => true,
+ Ty::Ref {
+ linearity: Linearity::Linear,
+ ..
+ } => true,
+ Ty::Region { inner, .. } => inner.is_linear(),
+ _ => false,
+ }
+ }
+}
+
+/// Literal values
+#[derive(Debug, Clone, PartialEq)]
+pub enum Literal {
+ Unit,
+ Bool(bool),
+ I32(i32),
+ I64(i64),
+ F32(f32),
+ F64(f64),
+ String(String),
+}
+
+/// Pattern for destructuring
+#[derive(Debug, Clone, PartialEq)]
+pub enum Pattern {
+ /// Wildcard _
+ Wildcard,
+ /// Variable binding
+ Var(Var),
+ /// Pair destructuring (p1, p2)
+ Pair(Box, Box),
+ /// Unit ()
+ Unit,
+}
+
+/// Expressions
+#[derive(Debug, Clone, PartialEq)]
+pub struct Expr {
+ pub kind: ExprKind,
+ pub span: Span,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum ExprKind {
+ /// Literal value
+ Lit(Literal),
+
+ /// Variable reference
+ Var(Var),
+
+ // ===== String operations =====
+ /// String allocation: String.new@r("...")
+ StringNew { region: RegionName, value: String },
+
+ /// String concatenation (consumes both)
+ StringConcat { left: Box, right: Box },
+
+ /// String length (borrows)
+ StringLen(Box),
+
+ // ===== Bindings =====
+ /// Let binding: let x = e1 in e2
+ Let {
+ name: Var,
+ ty: Option,
+ value: Box,
+ body: Box,
+ },
+
+ /// Linear let binding: let! x = e1 in e2
+ LetLin {
+ name: Var,
+ ty: Option,
+ value: Box,
+ body: Box,
+ },
+
+ // ===== Functions =====
+ /// Lambda: fn(x: T) -> e
+ Lambda {
+ param: Var,
+ param_ty: Ty,
+ body: Box,
+ },
+
+ /// Application: e1 e2
+ App { func: Box, arg: Box },
+
+ // ===== Products =====
+ /// Pair: (e1, e2)
+ Pair { left: Box, right: Box },
+
+ /// First projection: e.0
+ Fst(Box),
+
+ /// Second projection: e.1
+ Snd(Box),
+
+ // ===== Sums =====
+ /// Left injection: inl[T] e
+ Inl { ty: Ty, value: Box },
+
+ /// Right injection: inr[T] e
+ Inr { ty: Ty, value: Box },
+
+ /// Case analysis
+ Case {
+ scrutinee: Box,
+ left_var: Var,
+ left_body: Box,
+ right_var: Var,
+ right_body: Box,
+ },
+
+ // ===== Control flow =====
+ /// Conditional: if e1 then e2 else e3
+ If {
+ cond: Box,
+ then_branch: Box,
+ else_branch: Box,
+ },
+
+ // ===== Regions =====
+ /// Region scope: region r { e }
+ Region { name: RegionName, body: Box },
+
+ // ===== Borrowing =====
+ /// Create borrow: &e
+ Borrow(Box),
+
+ /// Dereference: *e
+ Deref(Box),
+
+ // ===== Resource management =====
+ /// Explicit drop: drop(e)
+ Drop(Box),
+
+ /// Explicit copy (unrestricted only): copy(e)
+ Copy(Box),
+
+ // ===== Blocks =====
+ /// Sequence of expressions
+ Block(Vec),
+}
+
+impl Expr {
+ pub fn new(kind: ExprKind, span: Span) -> Self {
+ Self { kind, span }
+ }
+
+ pub fn dummy(kind: ExprKind) -> Self {
+ Self {
+ kind,
+ span: Span::dummy(),
+ }
+ }
+}
+
+/// Top-level declarations
+#[derive(Debug, Clone, PartialEq)]
+pub enum Decl {
+ /// Function definition
+ Fn {
+ name: Var,
+ params: Vec<(Var, Ty)>,
+ ret_ty: Ty,
+ body: Expr,
+ },
+
+ /// Type alias
+ Type { name: Var, ty: Ty },
+}
+
+/// A complete module
+#[derive(Debug, Clone, PartialEq)]
+pub struct Module {
+ pub name: SmolStr,
+ pub decls: Vec,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn string_is_linear() {
+ let ty = Ty::String("r".into());
+ assert!(ty.is_linear());
+ }
+
+ #[test]
+ fn base_is_unrestricted() {
+ let ty = Ty::Base(BaseTy::I32);
+ assert!(!ty.is_linear());
+ }
+}
diff --git a/src/ephapax-typing/Cargo.toml b/src/ephapax-typing/Cargo.toml
new file mode 100644
index 0000000..e776136
--- /dev/null
+++ b/src/ephapax-typing/Cargo.toml
@@ -0,0 +1,18 @@
+# SPDX-License-Identifier: EUPL-1.2
+# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+[package]
+name = "ephapax-typing"
+description = "Linear type checker for the Ephapax language"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+keywords.workspace = true
+categories.workspace = true
+
+[dependencies]
+ephapax-syntax = { workspace = true }
+thiserror = { workspace = true }
diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs
new file mode 100644
index 0000000..c194015
--- /dev/null
+++ b/src/ephapax-typing/src/lib.rs
@@ -0,0 +1,381 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! Ephapax Linear Type Checker
+//!
+//! Implements the typing rules from formal/Typing.v
+
+use ephapax_syntax::{BaseTy, Expr, ExprKind, Literal, RegionName, Ty, Var};
+use std::collections::HashMap;
+use thiserror::Error;
+
+/// Type checking errors
+#[derive(Error, Debug, Clone, PartialEq)]
+pub enum TypeError {
+ #[error("Linear variable `{0}` used more than once")]
+ LinearVariableReused(Var),
+
+ #[error("Linear variable `{0}` not consumed")]
+ LinearVariableNotConsumed(Var),
+
+ #[error("Variable `{0}` not found in scope")]
+ UnboundVariable(Var),
+
+ #[error("Region `{0}` not active")]
+ InactiveRegion(RegionName),
+
+ #[error("Type mismatch: expected {expected:?}, found {found:?}")]
+ TypeMismatch { expected: Ty, found: Ty },
+
+ #[error("Cannot copy linear type {0:?}")]
+ CannotCopyLinear(Ty),
+
+ #[error("Cannot drop unrestricted value (not needed)")]
+ UnnecessaryDrop,
+
+ #[error("Branch linearity mismatch: both branches must consume same linear variables")]
+ BranchLinearityMismatch,
+
+ #[error("String escapes its region `{0}`")]
+ RegionEscape(RegionName),
+}
+
+/// Typing context entry
+#[derive(Debug, Clone)]
+struct CtxEntry {
+ ty: Ty,
+ used: bool,
+}
+
+/// Typing context: tracks variables and their usage
+#[derive(Debug, Clone, Default)]
+pub struct Context {
+ /// Variable bindings: name -> (type, used)
+ vars: HashMap,
+ /// Active regions
+ regions: Vec,
+}
+
+impl Context {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Extend context with new binding
+ pub fn extend(&mut self, name: Var, ty: Ty) {
+ self.vars.insert(name, CtxEntry { ty, used: false });
+ }
+
+ /// Look up variable type
+ pub fn lookup(&self, name: &Var) -> Option<&Ty> {
+ self.vars.get(name).map(|e| &e.ty)
+ }
+
+ /// Mark variable as used (for linear variables)
+ pub fn mark_used(&mut self, name: &Var) -> Result<(), TypeError> {
+ if let Some(entry) = self.vars.get_mut(name) {
+ if entry.ty.is_linear() && entry.used {
+ return Err(TypeError::LinearVariableReused(name.clone()));
+ }
+ entry.used = true;
+ Ok(())
+ } else {
+ Err(TypeError::UnboundVariable(name.clone()))
+ }
+ }
+
+ /// Check all linear variables have been used
+ pub fn check_all_linear_used(&self) -> Result<(), TypeError> {
+ for (name, entry) in &self.vars {
+ if entry.ty.is_linear() && !entry.used {
+ return Err(TypeError::LinearVariableNotConsumed(name.clone()));
+ }
+ }
+ Ok(())
+ }
+
+ /// Enter a new region
+ pub fn enter_region(&mut self, name: RegionName) {
+ self.regions.push(name);
+ }
+
+ /// Exit region
+ pub fn exit_region(&mut self) {
+ self.regions.pop();
+ }
+
+ /// Check if region is active
+ pub fn region_active(&self, name: &RegionName) -> bool {
+ self.regions.contains(name)
+ }
+}
+
+/// Type checker state
+pub struct TypeChecker {
+ ctx: Context,
+}
+
+impl TypeChecker {
+ pub fn new() -> Self {
+ Self { ctx: Context::new() }
+ }
+
+ /// Type check an expression
+ pub fn check(&mut self, expr: &Expr) -> Result {
+ match &expr.kind {
+ ExprKind::Lit(lit) => self.check_lit(lit),
+ ExprKind::Var(name) => self.check_var(name),
+ ExprKind::StringNew { region, .. } => self.check_string_new(region),
+ ExprKind::StringConcat { left, right } => self.check_string_concat(left, right),
+ ExprKind::Let {
+ name,
+ ty,
+ value,
+ body,
+ } => self.check_let(name, ty.as_ref(), value, body),
+ ExprKind::Lambda {
+ param,
+ param_ty,
+ body,
+ } => self.check_lambda(param, param_ty, body),
+ ExprKind::App { func, arg } => self.check_app(func, arg),
+ ExprKind::If {
+ cond,
+ then_branch,
+ else_branch,
+ } => self.check_if(cond, then_branch, else_branch),
+ ExprKind::Region { name, body } => self.check_region(name, body),
+ ExprKind::Borrow(inner) => self.check_borrow(inner),
+ ExprKind::Drop(inner) => self.check_drop(inner),
+ ExprKind::Copy(inner) => self.check_copy(inner),
+ _ => todo!("Type checking for {:?}", expr.kind),
+ }
+ }
+
+ fn check_lit(&self, lit: &Literal) -> Result {
+ Ok(match lit {
+ Literal::Unit => Ty::Base(BaseTy::Unit),
+ Literal::Bool(_) => Ty::Base(BaseTy::Bool),
+ Literal::I32(_) => Ty::Base(BaseTy::I32),
+ Literal::I64(_) => Ty::Base(BaseTy::I64),
+ Literal::F32(_) => Ty::Base(BaseTy::F32),
+ Literal::F64(_) => Ty::Base(BaseTy::F64),
+ Literal::String(_) => {
+ // String literals need a region - this is a parse error
+ panic!("String literals must be allocated with String.new@r")
+ }
+ })
+ }
+
+ fn check_var(&mut self, name: &Var) -> Result {
+ let ty = self
+ .ctx
+ .lookup(name)
+ .ok_or_else(|| TypeError::UnboundVariable(name.clone()))?
+ .clone();
+
+ // Mark linear variables as used
+ if ty.is_linear() {
+ self.ctx.mark_used(name)?;
+ }
+
+ Ok(ty)
+ }
+
+ fn check_string_new(&self, region: &RegionName) -> Result {
+ if !self.ctx.region_active(region) {
+ return Err(TypeError::InactiveRegion(region.clone()));
+ }
+ Ok(Ty::String(region.clone()))
+ }
+
+ fn check_string_concat(&mut self, left: &Expr, right: &Expr) -> Result {
+ let left_ty = self.check(left)?;
+ let right_ty = self.check(right)?;
+
+ match (&left_ty, &right_ty) {
+ (Ty::String(r1), Ty::String(r2)) if r1 == r2 => Ok(Ty::String(r1.clone())),
+ _ => Err(TypeError::TypeMismatch {
+ expected: left_ty,
+ found: right_ty,
+ }),
+ }
+ }
+
+ fn check_let(
+ &mut self,
+ name: &Var,
+ _ty: Option<&Ty>,
+ value: &Expr,
+ body: &Expr,
+ ) -> Result {
+ let value_ty = self.check(value)?;
+ self.ctx.extend(name.clone(), value_ty);
+ let body_ty = self.check(body)?;
+
+ // Ensure linear variable was consumed
+ if let Some(entry) = self.ctx.vars.get(name) {
+ if entry.ty.is_linear() && !entry.used {
+ return Err(TypeError::LinearVariableNotConsumed(name.clone()));
+ }
+ }
+
+ Ok(body_ty)
+ }
+
+ fn check_lambda(&mut self, param: &Var, param_ty: &Ty, body: &Expr) -> Result {
+ self.ctx.extend(param.clone(), param_ty.clone());
+ let body_ty = self.check(body)?;
+
+ // Check linear param was consumed
+ if let Some(entry) = self.ctx.vars.get(param) {
+ if entry.ty.is_linear() && !entry.used {
+ return Err(TypeError::LinearVariableNotConsumed(param.clone()));
+ }
+ }
+
+ Ok(Ty::Fun {
+ param: Box::new(param_ty.clone()),
+ ret: Box::new(body_ty),
+ })
+ }
+
+ fn check_app(&mut self, func: &Expr, arg: &Expr) -> Result {
+ let func_ty = self.check(func)?;
+ let arg_ty = self.check(arg)?;
+
+ match func_ty {
+ Ty::Fun { param, ret } => {
+ if *param == arg_ty {
+ Ok(*ret)
+ } else {
+ Err(TypeError::TypeMismatch {
+ expected: *param,
+ found: arg_ty,
+ })
+ }
+ }
+ _ => Err(TypeError::TypeMismatch {
+ expected: Ty::Fun {
+ param: Box::new(arg_ty.clone()),
+ ret: Box::new(Ty::Base(BaseTy::Unit)),
+ },
+ found: func_ty,
+ }),
+ }
+ }
+
+ fn check_if(
+ &mut self,
+ cond: &Expr,
+ then_branch: &Expr,
+ else_branch: &Expr,
+ ) -> Result {
+ let cond_ty = self.check(cond)?;
+
+ if cond_ty != Ty::Base(BaseTy::Bool) {
+ return Err(TypeError::TypeMismatch {
+ expected: Ty::Base(BaseTy::Bool),
+ found: cond_ty,
+ });
+ }
+
+ // Both branches must have same type and consume same linear resources
+ let then_ty = self.check(then_branch)?;
+ let else_ty = self.check(else_branch)?;
+
+ if then_ty != else_ty {
+ return Err(TypeError::TypeMismatch {
+ expected: then_ty,
+ found: else_ty,
+ });
+ }
+
+ Ok(then_ty)
+ }
+
+ fn check_region(&mut self, name: &RegionName, body: &Expr) -> Result {
+ self.ctx.enter_region(name.clone());
+ let body_ty = self.check(body)?;
+ self.ctx.exit_region();
+
+ // Check no strings from this region escape
+ if let Ty::String(r) = &body_ty {
+ if r == name {
+ return Err(TypeError::RegionEscape(name.clone()));
+ }
+ }
+
+ Ok(body_ty)
+ }
+
+ fn check_borrow(&mut self, inner: &Expr) -> Result {
+ // Borrowing does not consume the resource
+ let inner_ty = self.check(inner)?;
+ Ok(Ty::Borrow(Box::new(inner_ty)))
+ }
+
+ fn check_drop(&mut self, inner: &Expr) -> Result {
+ let inner_ty = self.check(inner)?;
+
+ if !inner_ty.is_linear() {
+ return Err(TypeError::UnnecessaryDrop);
+ }
+
+ Ok(Ty::Base(BaseTy::Unit))
+ }
+
+ fn check_copy(&mut self, inner: &Expr) -> Result {
+ let inner_ty = self.check(inner)?;
+
+ if inner_ty.is_linear() {
+ return Err(TypeError::CannotCopyLinear(inner_ty));
+ }
+
+ Ok(Ty::Prod {
+ left: Box::new(inner_ty.clone()),
+ right: Box::new(inner_ty),
+ })
+ }
+}
+
+impl Default for TypeChecker {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use ephapax_syntax::Span;
+
+ fn dummy_expr(kind: ExprKind) -> Expr {
+ Expr::new(kind, Span::dummy())
+ }
+
+ #[test]
+ fn test_literal_typing() {
+ let mut tc = TypeChecker::new();
+ let expr = dummy_expr(ExprKind::Lit(Literal::I32(42)));
+ assert_eq!(tc.check(&expr).unwrap(), Ty::Base(BaseTy::I32));
+ }
+
+ #[test]
+ fn test_linear_variable_reuse() {
+ let mut tc = TypeChecker::new();
+ tc.ctx.enter_region("r".into());
+ tc.ctx.extend("s".into(), Ty::String("r".into()));
+
+ // First use - OK
+ let var = dummy_expr(ExprKind::Var("s".into()));
+ assert!(tc.check(&var).is_ok());
+
+ // Second use - Error
+ let var2 = dummy_expr(ExprKind::Var("s".into()));
+ assert!(matches!(
+ tc.check(&var2),
+ Err(TypeError::LinearVariableReused(_))
+ ));
+ }
+}
diff --git a/src/ephapax-wasm/Cargo.toml b/src/ephapax-wasm/Cargo.toml
new file mode 100644
index 0000000..8067847
--- /dev/null
+++ b/src/ephapax-wasm/Cargo.toml
@@ -0,0 +1,18 @@
+# SPDX-License-Identifier: EUPL-1.2
+# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+[package]
+name = "ephapax-wasm"
+description = "WebAssembly code generator for the Ephapax language"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+keywords.workspace = true
+categories.workspace = true
+
+[dependencies]
+ephapax-syntax = { workspace = true }
+wasm-encoder = { workspace = true }
diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs
new file mode 100644
index 0000000..b854a5d
--- /dev/null
+++ b/src/ephapax-wasm/src/lib.rs
@@ -0,0 +1,463 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! Ephapax WASM Code Generator
+//!
+//! Compiles Ephapax IR to WebAssembly with explicit memory management.
+//!
+//! ## String Representation
+//!
+//! Strings are represented as (ptr: i32, len: i32) pairs in linear memory.
+//! The ptr points to UTF-8 encoded bytes.
+//!
+//! ## Memory Layout
+//!
+//! ```text
+//! +------------------+
+//! | Region Metadata | <- 0x0000
+//! +------------------+
+//! | String Data | <- bump allocated
+//! +------------------+
+//! | Free Space |
+//! +------------------+
+//! | Stack (grows down)| <- top of memory
+//! +------------------+
+//! ```
+
+use wasm_encoder::{
+ CodeSection, ExportKind, ExportSection, Function, FunctionSection, Instruction, MemorySection,
+ MemoryType, Module, TypeSection, ValType,
+};
+
+/// WASM representation of a string: (pointer, length)
+pub const STRING_SIZE: u32 = 8; // 2 x i32
+
+/// Region metadata size
+pub const REGION_HEADER_SIZE: u32 = 16;
+
+/// Initial memory pages (64KB each)
+pub const INITIAL_PAGES: u64 = 1;
+
+/// Maximum memory pages
+pub const MAX_PAGES: u64 = 256; // 16MB max
+
+/// Code generator state
+pub struct Codegen {
+ /// Current bump pointer for allocations
+ bump_ptr: u32,
+ /// Region stack for tracking active regions
+ region_stack: Vec,
+ /// Generated WASM module
+ module: Module,
+}
+
+#[derive(Debug, Clone)]
+struct RegionInfo {
+ /// Region name
+ #[allow(dead_code)]
+ name: String,
+ /// Start of region allocations
+ #[allow(dead_code)]
+ start_ptr: u32,
+}
+
+impl Codegen {
+ pub fn new() -> Self {
+ Self {
+ bump_ptr: REGION_HEADER_SIZE,
+ region_stack: Vec::new(),
+ module: Module::new(),
+ }
+ }
+
+ /// Generate the complete WASM module
+ pub fn generate(&mut self) -> Vec {
+ self.emit_types();
+ self.emit_memory();
+ self.emit_runtime_functions();
+ self.emit_exports();
+ self.module.clone().finish()
+ }
+
+ fn emit_types(&mut self) {
+ let mut types = TypeSection::new();
+
+ // Type 0: () -> i32 (allocator, getters)
+ types.ty().function(vec![], vec![ValType::I32]);
+
+ // Type 1: (i32) -> () (free, setters)
+ types.ty().function(vec![ValType::I32], vec![]);
+
+ // Type 2: (i32, i32) -> i32 (string ops with ptr+len)
+ types
+ .ty()
+ .function(vec![ValType::I32, ValType::I32], vec![ValType::I32]);
+
+ // Type 3: (i32, i32, i32, i32) -> i64 (string concat)
+ types.ty().function(
+ vec![ValType::I32, ValType::I32, ValType::I32, ValType::I32],
+ vec![ValType::I64],
+ );
+
+ // Type 4: () -> () (region management)
+ types.ty().function(vec![], vec![]);
+
+ self.module.section(&types);
+ }
+
+ fn emit_memory(&mut self) {
+ let mut memories = MemorySection::new();
+ memories.memory(MemoryType {
+ minimum: INITIAL_PAGES,
+ maximum: Some(MAX_PAGES),
+ memory64: false,
+ shared: false,
+ page_size_log2: None,
+ });
+ self.module.section(&memories);
+ }
+
+ fn emit_runtime_functions(&mut self) {
+ let mut functions = FunctionSection::new();
+ let mut code = CodeSection::new();
+
+ // Function 0: __ephapax_bump_alloc(size: i32) -> ptr: i32
+ functions.function(2);
+ code.function(&self.gen_bump_alloc());
+
+ // Function 1: __ephapax_string_new(ptr: i32, len: i32) -> handle: i32
+ functions.function(2);
+ code.function(&self.gen_string_new());
+
+ // Function 2: __ephapax_string_len(handle: i32) -> len: i32
+ functions.function(2);
+ code.function(&self.gen_string_len());
+
+ // Function 3: __ephapax_string_concat(h1: i32, h2: i32) -> handle: i32
+ functions.function(2);
+ code.function(&self.gen_string_concat());
+
+ // Function 4: __ephapax_string_drop(handle: i32)
+ functions.function(1);
+ code.function(&self.gen_string_drop());
+
+ // Function 5: __ephapax_region_enter()
+ functions.function(4);
+ code.function(&self.gen_region_enter());
+
+ // Function 6: __ephapax_region_exit()
+ functions.function(4);
+ code.function(&self.gen_region_exit());
+
+ self.module.section(&functions);
+ self.module.section(&code);
+ }
+
+ /// Bump allocator: advances pointer, returns old value
+ fn gen_bump_alloc(&self) -> Function {
+ let mut f = Function::new(vec![(1, ValType::I32)]); // local for old_ptr
+
+ // old_ptr = global.get $bump_ptr
+ f.instruction(&Instruction::I32Const(0)); // Address of bump_ptr global
+ f.instruction(&Instruction::I32Load(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+ f.instruction(&Instruction::LocalSet(2)); // Store in local
+
+ // global.set $bump_ptr (old_ptr + size)
+ f.instruction(&Instruction::I32Const(0));
+ f.instruction(&Instruction::LocalGet(2)); // old_ptr
+ f.instruction(&Instruction::LocalGet(0)); // size parameter
+ f.instruction(&Instruction::I32Add);
+ f.instruction(&Instruction::I32Store(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+
+ // return old_ptr
+ f.instruction(&Instruction::LocalGet(2));
+ f.instruction(&Instruction::End);
+
+ f
+ }
+
+ /// String allocation: copies data and returns handle
+ fn gen_string_new(&self) -> Function {
+ let mut f = Function::new(vec![(1, ValType::I32)]); // local for handle
+
+ // Allocate space for string header (ptr + len = 8 bytes)
+ f.instruction(&Instruction::I32Const(STRING_SIZE as i32));
+ f.instruction(&Instruction::I32Const(0)); // dummy second param
+ f.instruction(&Instruction::Call(0)); // __ephapax_bump_alloc
+ f.instruction(&Instruction::LocalSet(2)); // handle
+
+ // Store ptr at handle
+ f.instruction(&Instruction::LocalGet(2));
+ f.instruction(&Instruction::LocalGet(0)); // ptr param
+ f.instruction(&Instruction::I32Store(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+
+ // Store len at handle + 4
+ f.instruction(&Instruction::LocalGet(2));
+ f.instruction(&Instruction::LocalGet(1)); // len param
+ f.instruction(&Instruction::I32Store(wasm_encoder::MemArg {
+ offset: 4,
+ align: 2,
+ memory_index: 0,
+ }));
+
+ // Return handle
+ f.instruction(&Instruction::LocalGet(2));
+ f.instruction(&Instruction::End);
+
+ f
+ }
+
+ /// Get string length from handle
+ fn gen_string_len(&self) -> Function {
+ let mut f = Function::new(vec![]);
+
+ // Load len from handle + 4
+ f.instruction(&Instruction::LocalGet(0)); // handle
+ f.instruction(&Instruction::I32Load(wasm_encoder::MemArg {
+ offset: 4,
+ align: 2,
+ memory_index: 0,
+ }));
+ f.instruction(&Instruction::End);
+
+ f
+ }
+
+ /// Concatenate two strings (consumes both handles)
+ fn gen_string_concat(&self) -> Function {
+ let mut f = Function::new(vec![
+ (1, ValType::I32), // ptr1
+ (1, ValType::I32), // len1
+ (1, ValType::I32), // ptr2
+ (1, ValType::I32), // len2
+ (1, ValType::I32), // new_ptr
+ (1, ValType::I32), // new_handle
+ ]);
+
+ // Load ptr1, len1 from handle1
+ f.instruction(&Instruction::LocalGet(0)); // handle1
+ f.instruction(&Instruction::I32Load(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+ f.instruction(&Instruction::LocalSet(2)); // ptr1
+
+ f.instruction(&Instruction::LocalGet(0));
+ f.instruction(&Instruction::I32Load(wasm_encoder::MemArg {
+ offset: 4,
+ align: 2,
+ memory_index: 0,
+ }));
+ f.instruction(&Instruction::LocalSet(3)); // len1
+
+ // Load ptr2, len2 from handle2
+ f.instruction(&Instruction::LocalGet(1)); // handle2
+ f.instruction(&Instruction::I32Load(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+ f.instruction(&Instruction::LocalSet(4)); // ptr2
+
+ f.instruction(&Instruction::LocalGet(1));
+ f.instruction(&Instruction::I32Load(wasm_encoder::MemArg {
+ offset: 4,
+ align: 2,
+ memory_index: 0,
+ }));
+ f.instruction(&Instruction::LocalSet(5)); // len2
+
+ // Allocate new buffer: len1 + len2
+ f.instruction(&Instruction::LocalGet(3)); // len1
+ f.instruction(&Instruction::LocalGet(5)); // len2
+ f.instruction(&Instruction::I32Add);
+ f.instruction(&Instruction::I32Const(0));
+ f.instruction(&Instruction::Call(0)); // __ephapax_bump_alloc
+ f.instruction(&Instruction::LocalSet(6)); // new_ptr
+
+ // memory.copy: dest=new_ptr, src=ptr1, len=len1
+ f.instruction(&Instruction::LocalGet(6));
+ f.instruction(&Instruction::LocalGet(2));
+ f.instruction(&Instruction::LocalGet(3));
+ f.instruction(&Instruction::MemoryCopy {
+ src_mem: 0,
+ dst_mem: 0,
+ });
+
+ // memory.copy: dest=new_ptr+len1, src=ptr2, len=len2
+ f.instruction(&Instruction::LocalGet(6));
+ f.instruction(&Instruction::LocalGet(3));
+ f.instruction(&Instruction::I32Add);
+ f.instruction(&Instruction::LocalGet(4));
+ f.instruction(&Instruction::LocalGet(5));
+ f.instruction(&Instruction::MemoryCopy {
+ src_mem: 0,
+ dst_mem: 0,
+ });
+
+ // Allocate new handle
+ f.instruction(&Instruction::I32Const(STRING_SIZE as i32));
+ f.instruction(&Instruction::I32Const(0));
+ f.instruction(&Instruction::Call(0));
+ f.instruction(&Instruction::LocalSet(7)); // new_handle
+
+ // Store ptr in new handle
+ f.instruction(&Instruction::LocalGet(7));
+ f.instruction(&Instruction::LocalGet(6));
+ f.instruction(&Instruction::I32Store(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+
+ // Store len in new handle
+ f.instruction(&Instruction::LocalGet(7));
+ f.instruction(&Instruction::LocalGet(3));
+ f.instruction(&Instruction::LocalGet(5));
+ f.instruction(&Instruction::I32Add);
+ f.instruction(&Instruction::I32Store(wasm_encoder::MemArg {
+ offset: 4,
+ align: 2,
+ memory_index: 0,
+ }));
+
+ // Return new handle
+ f.instruction(&Instruction::LocalGet(7));
+ f.instruction(&Instruction::End);
+
+ f
+ }
+
+ /// Drop a string handle (no-op in bump allocator, freed on region exit)
+ fn gen_string_drop(&self) -> Function {
+ let mut f = Function::new(vec![]);
+ // In a bump allocator with regions, individual drops are no-ops
+ // Memory is reclaimed when the region exits
+ f.instruction(&Instruction::End);
+ f
+ }
+
+ /// Enter a new region: save current bump pointer
+ fn gen_region_enter(&self) -> Function {
+ let mut f = Function::new(vec![]);
+
+ // Push current bump_ptr to region stack (at fixed location)
+ // Region stack at offset 4, stack pointer at offset 8
+ f.instruction(&Instruction::I32Const(8)); // region_sp address
+ f.instruction(&Instruction::I32Const(8));
+ f.instruction(&Instruction::I32Load(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+ f.instruction(&Instruction::I32Const(4));
+ f.instruction(&Instruction::I32Add); // new_sp = old_sp + 4
+
+ // Store current bump_ptr at stack[new_sp]
+ f.instruction(&Instruction::I32Const(0)); // bump_ptr address
+ f.instruction(&Instruction::I32Load(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+ f.instruction(&Instruction::I32Store(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+
+ f.instruction(&Instruction::End);
+ f
+ }
+
+ /// Exit region: restore bump pointer (frees all region allocations)
+ fn gen_region_exit(&self) -> Function {
+ let mut f = Function::new(vec![(1, ValType::I32)]); // local for saved_ptr
+
+ // Pop from region stack
+ f.instruction(&Instruction::I32Const(8)); // region_sp address
+ f.instruction(&Instruction::I32Load(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+ f.instruction(&Instruction::LocalTee(0)); // sp
+
+ // Load saved bump_ptr
+ f.instruction(&Instruction::I32Load(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+
+ // Restore bump_ptr
+ f.instruction(&Instruction::I32Const(0));
+ f.instruction(&Instruction::I32Store(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+
+ // Decrement region_sp
+ f.instruction(&Instruction::I32Const(8));
+ f.instruction(&Instruction::LocalGet(0));
+ f.instruction(&Instruction::I32Const(4));
+ f.instruction(&Instruction::I32Sub);
+ f.instruction(&Instruction::I32Store(wasm_encoder::MemArg {
+ offset: 0,
+ align: 2,
+ memory_index: 0,
+ }));
+
+ f.instruction(&Instruction::End);
+ f
+ }
+
+ fn emit_exports(&mut self) {
+ let mut exports = ExportSection::new();
+ exports.export("__ephapax_bump_alloc", ExportKind::Func, 0);
+ exports.export("__ephapax_string_new", ExportKind::Func, 1);
+ exports.export("__ephapax_string_len", ExportKind::Func, 2);
+ exports.export("__ephapax_string_concat", ExportKind::Func, 3);
+ exports.export("__ephapax_string_drop", ExportKind::Func, 4);
+ exports.export("__ephapax_region_enter", ExportKind::Func, 5);
+ exports.export("__ephapax_region_exit", ExportKind::Func, 6);
+ exports.export("memory", ExportKind::Memory, 0);
+ self.module.section(&exports);
+ }
+}
+
+impl Default for Codegen {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn generates_valid_wasm() {
+ let mut codegen = Codegen::new();
+ let wasm = codegen.generate();
+
+ // Basic validation: WASM magic number
+ assert_eq!(&wasm[0..4], b"\x00asm");
+ // Version 1
+ assert_eq!(&wasm[4..8], &[1, 0, 0, 0]);
+ }
+}