Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ There is no built-in CLI binary. If you need one for local development, embed `c

## What it is

Edge Python targets functional edge computing: first-class functions, lambdas, closures, decorators (including class decorators), generators, async/await with a built-in cooperative scheduler, comprehensions, structural pattern matching, and pure-function memoization. Classes exist as flat state containers with `__init__`, attributes, and methods — no inheritance, no `super()`, no descriptor protocol, and no dunder-method dispatch (operators and protocols dispatch on type tag, not user-class methods). Integers are 47-bit inline (overflow raises `OverflowError`); there is no bignum.
Edge Python targets functional edge computing: first-class functions, lambdas, closures, decorators (including class decorators), generators, async/await with a built-in cooperative scheduler, comprehensions, structural pattern matching, and pure-function memoization. Classes support single-level inheritance, `super()`, dunder-method dispatch (operators, indexing, iteration, context managers, etc.), and `@property` / `@x.setter`. Integers are 47-bit inline with automatic promotion to i128 LongInt on overflow; the hard cap is ±2^127.

Imports resolve at compile time through a host-injected resolver. Bare names walk up `packages.json` manifests; quoted specs (`"./util.py"`, `"https://..."`) are loaded verbatim and may carry a `#sha256-<hex>` integrity fragment. `.py` modules are compiled and run once; native modules dispatch via the `CallExtern` opcode (either a `.wasm` loaded by URL per the public ABI, or in-process Rust closures from the embedder). There is no bundled stdlib — modules are external artifacts.

Expand Down
5 changes: 3 additions & 2 deletions compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A compact, single-pass SSA-style bytecode compiler and stack VM for a functional

## 1. Paradigm

Edge Python targets functional edge computing. The language treats functions as first-class values: lambdas, higher-order functions, closures, comprehensions, decorators (including class decorators), generators, async/await, pattern matching, and pure-function memoization. Classes exist as flat state containers with `__init__`, instance attributes, and methods — no inheritance walking, no MRO, no `super()`, no descriptor protocol, and no dunder-method dispatch (operators, `with`, iteration, `len`, equality, etc. all dispatch on type tag, not on user-class methods). `__init__` is the only honoured magic method.
Edge Python targets functional edge computing. The language treats functions as first-class values: lambdas, higher-order functions, closures, comprehensions, decorators (including class decorators), generators, async/await, pattern matching, and pure-function memoization. Classes support single-level inheritance, `super()`, dunder protocol dispatch (operators, indexing, iteration, context managers, hashing, etc.), and `@property` / `@x.setter`.

`import` and `from <spec> import names` resolve at compile time through a host-injected resolver (see `modules/packages/`, manifest = `packages.json`). Each module is compiled and initialised once: the parser registers it in the importing chunk's `imports` list, the VM runs every imported module's top level in dependency order, and importers reach the resulting `HeapObj::Module` value via `OpCode::LoadModule`. Native modules dispatch via `CallExtern` for fast call-site fusion. Quoted specs may carry a `#sha256-<hex>` integrity fragment.

Expand All @@ -25,7 +25,7 @@ What this leaves is a small, fast, deterministic core: 47-bit inline integers +
* **VM**: Stack-based interpreter over `Vec<Instruction>`, where each `Instruction` is `(opcode: OpCode, operand: u16)`. The hot loop lives in `modules/vm/dispatch.rs` as a flat `match` on the opcode (Rust lowers it to a jump table); the VM struct and constructor live in `modules/vm/mod.rs`, with `init.rs` / `helpers.rs` / `gc.rs` covering module init, stack/iter primitives, and the collector. The hot path is split across handler modules (`handlers/{arith,data,format,function,methods,methods_helpers,mod}.rs`). `LoadAttr + Call(0)` is fused into a `CallMethod` / `CallMethodArgs` super-instruction at first execution and cached per call site.
* **Inline Caching**: Per-instruction type-recording cache (`modules/vm/cache.rs`) for arithmetic and comparisons. After 4 stable hits the IC promotes the slot to a typed `FastOp` (`AddInt`, `AddFloat`, `LtFloat`, `EqStr`, ...); the fast path keeps a type-tag guard so a miss falls back to the generic handler.
* **Template Memoization**: Pure functions called with the same arguments return a cached result after 2 hits, bypassing full execution. Functions are tagged impure on first observed side effect (`StoreItem`, `StoreAttr`, `print`, `input`, `raise`, `yield`).
* **Memory**: NaN-boxed 64-bit `Val` (47-bit signed inline int, IEEE-754 float, bool, None, 28-bit heap index). Heap is an arena of `HeapObj` slots managed by a mark-and-sweep GC. Strings and bytes ≤ 128 bytes are interned. **Integers are a hard 47 bits** (±140,737,488,355,327); overflow raises `OverflowError`. There is no bignum fallback — this is paradigm-level, not a TODO.
* **Memory**: NaN-boxed 64-bit `Val` (47-bit signed inline int, IEEE-754 float, bool, None, 28-bit heap index). Heap is an arena of `HeapObj` slots managed by a mark-and-sweep GC. Strings and bytes ≤ 128 bytes are interned. **Integers are 47-bit inline with automatic i128 (`LongInt`) promotion on overflow**, hard-capped at ±2^127.

---

Expand Down Expand Up @@ -141,6 +141,7 @@ Mark-and-sweep with roots: operand stack, with-stack, pending yields, event queu
│ ├── mod.rs
│ ├── arith.rs
│ ├── data.rs
│ ├── dunder.rs
│ ├── format.rs
│ ├── function.rs
│ ├── methods.rs
Expand Down
35 changes: 30 additions & 5 deletions compiler/src/modules/parser/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,16 +522,18 @@ impl<'src, I: Iterator<Item = Token>> Parser<'src, I> {
self.commit_block();
}

/* with / async with: SetupWith per CM, ExitWith 1:1 on unwind. */
/* with / async with: each CM gets its own implicit `SetupExcept` so the per-CM cleanup pad can run `__exit__(exc_type, exc, None)` and honour the suppression contract. Normal exit pops the except frame before running `__exit__(None, None, None)`. */

pub(super) fn with_stmt_inner(&mut self, is_async: bool) {
self.advance();
let operand = is_async as u16;
let mut cm_count: u16 = 0;
let mut setup_except_idxs: Vec<usize> = Vec::new();
loop {
self.expr();
self.chunk.emit(OpCode::SetupWith, operand);
cm_count += 1;
// Implicit `SetupExcept` per CM; handler IP patched once the cleanup pad is emitted.
setup_except_idxs.push(self.chunk.instructions.len());
self.chunk.emit(OpCode::SetupExcept, 0);
if self.eat_if(TokenType::As) {
let name = self.advance_text();
self.store_name(name);
Expand All @@ -540,10 +542,33 @@ impl<'src, I: Iterator<Item = Token>> Parser<'src, I> {
}
self.eat(TokenType::Colon);
self.compile_block();
// Paired ExitWith for each SetupWith.
for _ in 0..cm_count {

// Normal exit: innermost first. PopExcept BEFORE ExitWith so a raising `__exit__(None,...)` propagates to the outer CM's cleanup, matching CPython.
let n = setup_except_idxs.len();
let normal_exit_start = self.chunk.instructions.len();
for _ in 0..n {
self.chunk.emit(OpCode::PopExcept, 0);
self.chunk.emit(OpCode::ExitWith, operand);
}
let skip_cleanup_jump = self.chunk.instructions.len();
self.chunk.emit(OpCode::Jump, 0);

// Cleanup pads: per-CM in source order (outermost first). Each runs `WithCleanup` then jumps into the normal-exit sequence at the point right after its own slot, so outer CMs get their `__exit__(None, None, None)` on a suppression path.
let mut cleanup_pad_positions: Vec<usize> = Vec::with_capacity(n);
for i in 0..n {
cleanup_pad_positions.push(self.chunk.instructions.len());
self.chunk.emit(OpCode::WithCleanup, 0);
// `normal_exit_start + 2*(n-i)` lands past the PopExcept+ExitWith pairs for CMs i..n-1 (innermost). i == 0 lands at the `Jump @end` which falls through to `end`.
let target = (normal_exit_start + 2 * (n - i)) as u16;
self.chunk.emit(OpCode::Jump, target);
}
let end_label = self.chunk.instructions.len();

// Patch SetupExcept handler IPs and the skip-cleanup jump.
for (i, &se_idx) in setup_except_idxs.iter().enumerate() {
self.chunk.instructions[se_idx].operand = cleanup_pad_positions[i] as u16;
}
self.chunk.instructions[skip_cleanup_jump].operand = end_label as u16;
}

/* Delegates to imports.rs; compile-time only — no import opcodes reach the VM. */
Expand Down
8 changes: 7 additions & 1 deletion compiler/src/modules/parser/literals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,9 +396,12 @@ impl<'src, I: Iterator<Item = Token>> Parser<'src, I> {
"<missing>".to_string()
};

// Bases are pushed left-to-right; `MakeClass` pops `num_bases` and stores them in the Class.
let mut num_bases: u16 = 0;
if self.eat_if(TokenType::Lpar) {
while !matches!(self.peek(), Some(TokenType::Rpar) | None) {
self.expr();
num_bases = num_bases.saturating_add(1);
if !self.eat_if(TokenType::Comma) { break; }
}
self.eat(TokenType::Rpar);
Expand All @@ -409,8 +412,11 @@ impl<'src, I: Iterator<Item = Token>> Parser<'src, I> {
let body = self.with_fresh_chunk(|s| s.compile_block());

let ci = self.chunk.classes.len() as u16;
// Operand packs `(num_bases << 8) | class_idx`; each field is one byte to keep the dispatch decode cheap.
if ci > 0xFF { self.error("too many classes in this scope (limit 255)"); return; }
if num_bases > 0xFF { self.error("too many base classes (limit 255)"); return; }
self.chunk.classes.push(body);
self.chunk.emit(OpCode::MakeClass, ci);
self.chunk.emit(OpCode::MakeClass, (num_bases << 8) | ci);

// Each decorator Calls with the previous result, same as for functions.
for _ in 0..decorators {
Expand Down
7 changes: 6 additions & 1 deletion compiler/src/modules/parser/stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,12 @@ impl<'src, I: Iterator<Item = Token>> Parser<'src, I> {
true
}
}
Some(TokenType::Lpar) => self.call(name),
Some(TokenType::Lpar) => {
// `name(...)` at statement level: allow postfix chains like `super().__init__(x)`.
let leaves = self.call(name);
if leaves { self.expr_tails(); }
leaves
}
_ => {
self.emit_load_ssa(name);
self.expr_tails();
Expand Down
2 changes: 1 addition & 1 deletion compiler/src/modules/parser/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pub enum OpCode {
CallOrd, BuildDict, BuildList, NotEq, Lt, Gt, LtEq, GtEq, And, Or, Not, JumpIfFalse, Jump,
GetIter, ForIter, GetItem, Mod, Pow, FloorDiv, LoadTrue, LoadFalse, LoadNone, LoadAttr, StoreAttr,
BuildSlice, MakeClass, SetupExcept, PopExcept, Raise, BitAnd, BitOr, BitXor,
BitNot, Shl, Shr, In, NotIn, Is, IsNot, UnpackSequence, BuildTuple, SetupWith, ExitWith, Yield,
BitNot, Shl, Shr, In, NotIn, Is, IsNot, UnpackSequence, BuildTuple, SetupWith, ExitWith, WithCleanup, Yield,
Del, Assert, Global, Nonlocal, UnpackArgs, ListAppend, SetAdd, MapAdd, BuildSet, RaiseFrom,
UnpackEx, LoadEllipsis, Await, MakeCoroutine, StoreItem, Dup2,
JumpIfFalseOrPop, JumpIfTrueOrPop, Dup, CallMethod, CallMethodArgs, CallAll, CallAny, CallBin,
Expand Down
12 changes: 8 additions & 4 deletions compiler/src/modules/vm/builtins/conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ use super::super::types::*;

impl<'a> VM<'a> {

pub fn call_str(&mut self) -> Result<(), VmErr> {
pub fn call_str(&mut self, chunk: &crate::modules::parser::SSAChunk, slots: &mut [Val]) -> Result<(), VmErr> {
let o = self.pop()?;
self.alloc_and_push_str(self.display(o))
let s = self.display_op(o, chunk, slots)?;
self.alloc_and_push_str(s)
}

pub fn call_bool(&mut self) -> Result<(), VmErr> {
let o = self.pop()?; self.push(Val::bool(self.truthy(o))); Ok(())
pub fn call_bool(&mut self, chunk: &crate::modules::parser::SSAChunk, slots: &mut [Val]) -> Result<(), VmErr> {
let o = self.pop()?;
let t = self.truthy_op(o, chunk, slots)?;
self.push(Val::bool(t));
Ok(())
}

pub fn call_type(&mut self) -> Result<(), VmErr> {
Expand Down
73 changes: 66 additions & 7 deletions compiler/src/modules/vm/builtins/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,49 @@ use super::matches_exc_class;

impl<'a> VM<'a> {

pub fn call_repr(&mut self) -> Result<(), VmErr> {
/* `property(fget)` / `property(fget, fset)` — captures the descriptor pair the class chain hands to `LoadAttr` / `StoreAttr`. The `@x.setter` decorator builds the second form via `PropertySetter`. */
pub fn call_property(&mut self, argc: u16) -> Result<(), VmErr> {
let args = self.pop_n(argc as usize)?;
let (getter, setter) = match args.as_slice() {
[g] => (*g, Val::none()),
[g, s] => (*g, *s),
_ => return Err(cold_type("property() takes 1 or 2 arguments")),
};
let prop = self.heap.alloc(HeapObj::Property(getter, setter))?;
self.push(prop);
Ok(())
}

// `super()` zero-arg: reads the running method's `(class, self)` off the top frame and returns a Super proxy.
pub fn call_super(&mut self) -> Result<(), VmErr> {
let binding = self.call_stack.last()
.and_then(|f| f.current_class.zip(f.current_self));
let Some((class, recv)) = binding else {
return Err(VmErr::Runtime("super() must be called inside a method"));
};
let proxy = self.heap.alloc(HeapObj::Super(class, recv))?;
self.push(proxy);
Ok(())
}

pub fn call_repr(&mut self, chunk: &crate::modules::parser::SSAChunk, slots: &mut [Val]) -> Result<(), VmErr> {
let o = self.pop()?;
self.alloc_and_push_str(self.repr(o))
let s = self.repr_op(o, chunk, slots)?;
self.alloc_and_push_str(s)
}

pub fn call_callable(&mut self) -> Result<(), VmErr> {
let o = self.pop()?;
let result = if o.is_heap() {
matches!(self.heap.get(o),
match self.heap.get(o) {
HeapObj::Func(..) | HeapObj::BoundMethod(..)
| HeapObj::Type(_) | HeapObj::NativeFn(_))
| HeapObj::Type(_) | HeapObj::NativeFn(_)
| HeapObj::Class(..) | HeapObj::BoundUserMethod(..)
| HeapObj::Extern(_) => true,
// F2.5: instance is callable iff its class chain defines `__call__`.
HeapObj::Instance(cls, _) => self.lookup_class_member(*cls, "__call__").is_some(),
_ => false,
}
} else { false };
self.push(Val::bool(result));
Ok(())
Expand All @@ -30,9 +62,30 @@ impl<'a> VM<'a> {
Ok(())
}

pub fn call_hash(&mut self) -> Result<(), VmErr> {
pub fn call_hash(&mut self, chunk: &crate::modules::parser::SSAChunk, slots: &mut [Val]) -> Result<(), VmErr> {
use core::hash::{Hash, Hasher};
let o = self.pop()?;

// F2.7: instance dispatch — user `__hash__` wins; `__eq__` without `__hash__` makes the instance unhashable.
if o.is_heap() && let HeapObj::Instance(cls, _) = self.heap.get(o) {
let cls = *cls;
let has_hash = self.lookup_class_member(cls, "__hash__").is_some();
let has_eq = self.lookup_class_member(cls, "__eq__").is_some();
if has_hash {
let r = self.try_call_dunder(o, "__hash__", &[], chunk, slots)?
.ok_or_else(|| cold_type("__hash__ returned NotImplemented"))?;
if !r.is_int() {
return Err(cold_type("__hash__ must return int"));
}
self.push(Val::int(r.as_int() & Val::INT_MAX));
return Ok(());
}
if has_eq {
return Err(cold_type("unhashable type: instance defines __eq__ without __hash__"));
}
// Default fallback: pointer identity, mirroring Python's `object.__hash__`.
}

let mut h = crate::util::fx::FxHasher::default();
if o.is_int() { o.as_int().hash(&mut h); }
else if o.is_float() { o.as_float().to_bits().hash(&mut h); }
Expand All @@ -51,7 +104,7 @@ impl<'a> VM<'a> {
Ok(())
}

/* Type-name based isinstance check. Accepts Type or NativeFn (for the builtins-as-types case) on the right; allows int↔bool aliasing. */
/* Type-name based isinstance check. Accepts Type / NativeFn (builtin types) / user Class on the right; allows int↔bool aliasing and walks user inheritance via `is_subclass`. */
pub fn call_isinstance(&mut self) -> Result<(), VmErr> {
let (arg2, obj) = (self.pop()?, self.pop()?);
let obj_ty = self.type_name(obj);
Expand All @@ -65,6 +118,11 @@ impl<'a> VM<'a> {
}
} else { None };

// User-class membership uses heap identity, not type names, so capture the instance's class up-front.
let obj_class: Option<Val> = if obj.is_heap() {
if let HeapObj::Instance(cls, _) = self.heap.get(obj) { Some(*cls) } else { None }
} else { None };

let check_one = |t: Val, heap: &HeapPool| -> Result<bool, VmErr> {
if !t.is_heap() {
return Err(VmErr::Type("isinstance() arg 2 must be a type or tuple of types"));
Expand All @@ -90,6 +148,7 @@ impl<'a> VM<'a> {
|| (obj_ty == "bool" && name == "int")
)
}
HeapObj::Class(..) => Ok(obj_class.is_some_and(|c| heap.is_subclass(c, t))),
_ => Err(VmErr::Type("isinstance() arg 2 must be a type or tuple of types")),
}
};
Expand All @@ -99,7 +158,7 @@ impl<'a> VM<'a> {
}

let result = match self.heap.get(arg2) {
HeapObj::Type(_) | HeapObj::NativeFn(_) => check_one(arg2, &self.heap)?,
HeapObj::Type(_) | HeapObj::NativeFn(_) | HeapObj::Class(..) => check_one(arg2, &self.heap)?,
HeapObj::Tuple(items) => {
let items: Vec<Val> = items.clone();
items.iter().any(|&t| check_one(t, &self.heap).unwrap_or(false))
Expand Down
Loading
Loading