From 617dbeb9889f75358e1fab5d74aae4cc7f60e56e Mon Sep 17 00:00:00 2001 From: uditdey Date: Fri, 4 Sep 2026 13:53:59 +0000 Subject: [PATCH] First declosure impl --- compiler/src/declass/step_1.rs | 29 ++- compiler/src/declosure.rs | 317 +++++++++++++++++++++++++++++++++ compiler/src/flatten.rs | 2 +- compiler/src/main.rs | 6 + compiler/src/regify.rs | 6 +- docs/declass_algo.txt | 19 +- docs/declosure_algo.txt | 8 +- 7 files changed, 376 insertions(+), 11 deletions(-) create mode 100644 compiler/src/declosure.rs diff --git a/compiler/src/declass/step_1.rs b/compiler/src/declass/step_1.rs index 0141b53..d78e63e 100644 --- a/compiler/src/declass/step_1.rs +++ b/compiler/src/declass/step_1.rs @@ -10,6 +10,9 @@ //! `get_field "prototype" ; put_field @meth`; the field-initialiser tail is //! dropped. Detection (`find_class_seqs`) records the ranges; replacement //! (`ClassSequence::emit_prototype`) reads operands out of each matched range. +//! +//! Derived classes (`extends`) are unsupported and panic: prototype form would +//! have to flatten the parent's prototype in and lower `super`. use std::range::Range; @@ -160,6 +163,16 @@ fn find_class_seqs(mut ins: &[RegInstr]) -> Vec { fn match_class_seq(ins: &[RegInstr]) -> Result<(&[RegInstr], usize), &[RegInstr]> { let ins = match_one(ins, |i| i.op == OpCode::PushConst8)?; + + // A class build is `push_const8 ; define_class`. Derived classes (`extends`) + // are unsupported: prototype form would have to flatten the parent's + // prototype in and lower `super`, neither of which is implemented. + if let Some(i) = ins.first() { + if i.op == OpCode::DefineClass && is_derived(i) { + panic!("declass: derived classes (extends) are not supported"); + } + } + let ins = match_one(ins, |i| i.op == OpCode::DefineClass)?; let (ins, num_methods) = match_many(ins, match_method_seq); let ins = match_opt(ins, match_field_init_seq); @@ -167,6 +180,15 @@ fn match_class_seq(ins: &[RegInstr]) -> Result<(&[RegInstr], usize), &[RegInstr] Ok((ins, num_methods)) } +/// A derived class: `define_class` with the derived flag set. +fn is_derived(i: &RegInstr) -> bool { + matches!(i.imm, ImmOperand::AtomU8 { arg, .. } if arg & 1 == 1) +} + +// TODO: accessors are not handled. A getter/setter is a `define_method` with a +// non-zero kind in `arg`; this matcher takes it for an ordinary method, so it +// is wrongly lowered to a `put_field` (a data property) instead of an accessor +// property. It should instead make the whole class pass through untouched. fn match_method_seq(ins: &[RegInstr]) -> Result<&[RegInstr], &[RegInstr]> { let ins = match_one(ins, |i| i.op == OpCode::Fclosure8)?; let ins = match_one(ins, |i| i.op == OpCode::DefineMethod)?; @@ -177,7 +199,12 @@ fn match_field_init_seq(ins: &[RegInstr]) -> Result<&[RegInstr], &[RegInstr]> { let is_put_loc = |op| { matches!( op, - OpCode::PutLoc | OpCode::PutLoc0 | OpCode::PutLoc1 | OpCode::PutLoc2 | OpCode::PutLoc3 + OpCode::PutLoc + | OpCode::PutLoc8 + | OpCode::PutLoc0 + | OpCode::PutLoc1 + | OpCode::PutLoc2 + | OpCode::PutLoc3 ) }; diff --git a/compiler/src/declosure.rs b/compiler/src/declosure.rs new file mode 100644 index 0000000..31fceaa --- /dev/null +++ b/compiler/src/declosure.rs @@ -0,0 +1,317 @@ +//! declosure: closures become plain objects. +//! +//! A closure becomes an object holding its function plus its captured +//! variables (docs/declosure_algo.txt). Field 0 is the function; fields 1.. +//! are the captures, in capture order. The object doubles as the environment, +//! so there is no second env value to track. Field names are plain integers, +//! so a field operand reads `Atom(int(k))`. +//! +//! Three steps, one property each: +//! +//! 1. `take_env_param` -- every function takes the env as its +//! LAST parameter and reads its captures out of it: `get_var_ref` / +//! `put_var_ref` become field access on the env. +//! 2. `build_closure_objects` -- every `fclosure` builds the object: the +//! function in field 0, then one field per capture, loaded from the +//! parent frame. +//! 3. `route_calls_through_object` -- every plain call reads field 0 and +//! passes the object itself as the trailing env argument. +//! +//! Every function takes an env whether it captures anything or not, so step 3 +//! can append the object at a uniform call site. +//! +//! Two facts about the register form this leans on: +//! +//! * A function is a plain constant -- an index into the cpool. `fclosure` +//! becomes `push_const`, so it loads like any other const, and storing it +//! in a field is no different from storing a number. +//! * The callee of a call is its LAST argument (the deepest stack slot, +//! popped last), so step 3 reads `args.last()`. +//! +//! Known gaps, in rough order of how likely they are to bite: +//! +//! * A captured variable the parent later writes. The write produces a new +//! value instead of storing through the alias, so the closure never sees +//! it. A capture aliases the value a slot held at capture time. +//! * Non-local capture sources. Step 2 can materialise a capture that comes +//! from a parent local or arg. A capture that itself comes from the +//! parent's captures, a module binding, or a global is left as an +//! `undefined` placeholder -- it needs the parent to forward its own env. +//! * Foreign callees. Step 3 rewrites every plain call into a field-0 read. +//! That is only valid for functions we produced; a host builtin such as +//! `Math.sqrt` has no such field. +//! * Entry points. Step 1 gives every function an env parameter, but the +//! module root and any host-invoked function have no caller of ours to +//! supply one. +//! * `call_method` and `call_constructor` carry a receiver, so they belong +//! to the class passes; step 3 leaves them untouched. Same for `apply`. + +use crate::{ + flatten::CaptureFrom, + parse::{AtomRef, ImmOperand, JsValue, opcodes::OpCode}, + regify::{RegFn, RegInstr}, +}; + +pub fn declosure(reg_fns: Vec) -> Vec { + // Where each function's captures come from, per fn id. Snapshotted first, + // because step 1 clears the capture lists that step 2 reads. + let capture_sources: Vec> = reg_fns + .iter() + .map(|f| f.captures.iter().map(|c| c.from).collect()) + .collect(); + + reg_fns + .into_iter() + .map(|f| declosure_one(f, &capture_sources)) + .collect() +} + +fn declosure_one(reg_fn: RegFn, capture_sources: &[Vec]) -> RegFn { + let reg_fn = take_env_param(reg_fn); + let reg_fn = build_closure_objects(reg_fn, capture_sources); + route_calls_through_object(reg_fn) +} + +// ── 1. every function takes an env ───────────────────────────────────────── + +/// Append `env` as the last parameter -- existing arguments keep their numbers +/// -- and turn every capture access into a field access on it. +fn take_env_param(mut reg_fn: RegFn) -> RegFn { + let env_arg = reg_fn.arg_count; + reg_fn.arg_count += 1; + + let code = std::mem::take(&mut reg_fn.code); + let touches_captures = code.iter().any(|i| capture_access(i).is_some()); + + let mut out = Vec::with_capacity(code.len() + 1); + let env = reg_fn.fresh_val(); + + // Load the env once at entry -- only if a capture is actually accessed. + if touches_captures { + out.push(get_arg(env, env_arg)); + } + + for instr in code { + match capture_access(&instr) { + // A read keeps its destination, so downstream uses do not change. + Some(CaptureAccess::Read(k)) => { + out.push(get_field(int_field(k + 1), instr.dsts[0], env)) + } + Some(CaptureAccess::Write(k)) => { + out.push(put_field(int_field(k + 1), instr.args[0], env)) + } + None => out.push(instr), + } + } + + reg_fn.code = out; + reg_fn.captures.clear(); + reg_fn +} + +/// Which capture an instruction reads or writes, if any. +enum CaptureAccess { + Read(u32), + Write(u32), +} + +fn capture_access(i: &RegInstr) -> Option { + // The numbered forms fold the slot into the opcode; the rest carry it. + let slot = || match i.imm { + ImmOperand::VarRef(k) => k as u32, + _ => unreachable!("a var_ref opcode without a var_ref operand"), + }; + Some(match i.op { + OpCode::GetVarRef | OpCode::GetVarRefCheck => CaptureAccess::Read(slot()), + OpCode::GetVarRef0 => CaptureAccess::Read(0), + OpCode::GetVarRef1 => CaptureAccess::Read(1), + OpCode::GetVarRef2 => CaptureAccess::Read(2), + OpCode::GetVarRef3 => CaptureAccess::Read(3), + OpCode::PutVarRef | OpCode::PutVarRefCheck | OpCode::PutVarRefCheckInit => { + CaptureAccess::Write(slot()) + } + OpCode::PutVarRef0 => CaptureAccess::Write(0), + OpCode::PutVarRef1 => CaptureAccess::Write(1), + OpCode::PutVarRef2 => CaptureAccess::Write(2), + OpCode::PutVarRef3 => CaptureAccess::Write(3), + _ => return None, + }) +} + +// ── 2. every fclosure builds the object ──────────────────────────────────── + +/// Replace each `fclosure` with the object that stands for it: the function in +/// field 0, then one field per capture, each loaded from the parent frame. +fn build_closure_objects(mut reg_fn: RegFn, capture_sources: &[Vec]) -> RegFn { + let code = std::mem::take(&mut reg_fn.code); + let mut out = Vec::with_capacity(code.len()); + + for instr in code { + let Some(child) = closure_child(&instr, ®_fn.cpool) else { + out.push(instr); + continue; + }; + // The value the rest of the code already uses for this closure. + let closure = instr.dsts[0]; + let captures: &[CaptureFrom] = capture_sources + .get(child) + .map(Vec::as_slice) + .unwrap_or(&[]); + + // The function is a plain constant, so it loads with `push_const`. + let load = match instr.op { + OpCode::Fclosure8 => OpCode::PushConst8, + _ => OpCode::PushConst, + }; + let func = reg_fn.fresh_val(); + out.push(RegInstr::new(load, instr.imm, vec![func], vec![])); + + let mut obj = reg_fn.fresh_val(); + out.push(object(obj)); + + // Each define_field returns the object, so the stores chain. The LAST + // store must land in `closure`. + let dst = if captures.is_empty() { + closure + } else { + reg_fn.fresh_val() + }; + out.push(define_field(int_field(0), dst, func, obj)); + obj = dst; + + for (k, source) in captures.iter().enumerate() { + let value = reg_fn.fresh_val(); + out.push(load_capture(*source, value)); + + let dst = if k + 1 == captures.len() { + closure + } else { + reg_fn.fresh_val() + }; + out.push(define_field(int_field(k as u32 + 1), dst, value, obj)); + obj = dst; + } + } + + reg_fn.code = out; + reg_fn +} + +/// The nested function an `fclosure` instantiates, as a fn id. +fn closure_child(i: &RegInstr, cpool: &[JsValue]) -> Option { + if !matches!(i.op, OpCode::Fclosure | OpCode::Fclosure8) { + return None; + } + let ImmOperand::Const(index) = i.imm else { + return None; + }; + match cpool.get(index as usize) { + Some(JsValue::FnById(id)) => Some(*id), + _ => None, + } +} + +/// Load one captured value out of the parent frame into `into`. Only parent +/// locals and args are supported; any other source is a preserved gap and +/// loads `undefined`. +fn load_capture(source: CaptureFrom, into: usize) -> RegInstr { + match source { + CaptureFrom::ParentLocal(slot) => get_loc(into, slot), + CaptureFrom::ParentArg(slot) => get_arg(into, slot), + _ => RegInstr::new(OpCode::Undefined, ImmOperand::None, vec![into], vec![]), + } +} + +// ── 3. every call goes through the object ────────────────────────────────── + +/// A call names a closure object now, not a function. Read the function out of +/// field 0, and pass the object itself as the trailing env argument. +fn route_calls_through_object(mut reg_fn: RegFn) -> RegFn { + let code = std::mem::take(&mut reg_fn.code); + let mut out = Vec::with_capacity(code.len()); + + for instr in code { + let Some((op, argc)) = plain_call(&instr) else { + out.push(instr); + continue; + }; + + // The callee is the deepest stack slot, so it is popped last. + let closure = *instr.args.last().unwrap(); + let func = reg_fn.fresh_val(); + out.push(get_field(int_field(0), func, closure)); + + // Args become [ env, , func ]: the env is pushed last so + // it pops first, and the callee stays last. + let mut args = Vec::with_capacity(instr.args.len() + 1); + args.push(closure); + args.extend_from_slice(&instr.args[..instr.args.len() - 1]); + args.push(func); + + out.push(RegInstr::new( + op, + ImmOperand::Npop(argc + 1), + instr.dsts, + args, + )); + } + + reg_fn.code = out; + reg_fn +} + +/// The opcode to emit and the argument count, for a plain call. `None` if this +/// is not one: the receiver-carrying calls (`call_method`, `call_constructor`, +/// `apply`) are deliberately excluded. +/// +/// A tail call stays a tail call -- it pushes nothing and returns, so turning +/// it into an ordinary `call` would leave a value behind and drop the return. +fn plain_call(i: &RegInstr) -> Option<(OpCode, u16)> { + if i.args.is_empty() { + return None; + } + let npop = || match i.imm { + ImmOperand::Npop(n) => n, + _ => unreachable!("a call opcode without an npop operand"), + }; + Some(match i.op { + OpCode::Call0 => (OpCode::Call, 0), + OpCode::Call1 => (OpCode::Call, 1), + OpCode::Call2 => (OpCode::Call, 2), + OpCode::Call3 => (OpCode::Call, 3), + OpCode::Call => (OpCode::Call, npop()), + OpCode::TailCall => (OpCode::TailCall, npop()), + _ => return None, + }) +} + +// ── emitted instructions ─────────────────────────────────────────────────── + +fn get_arg(dst: usize, arg: usize) -> RegInstr { + RegInstr::new(OpCode::GetArg, ImmOperand::Arg(arg as u16), vec![dst], vec![]) +} + +fn get_loc(dst: usize, loc: usize) -> RegInstr { + RegInstr::new(OpCode::GetLoc, ImmOperand::Loc(loc as u16), vec![dst], vec![]) +} + +fn object(dst: usize) -> RegInstr { + RegInstr::new(OpCode::Object, ImmOperand::None, vec![dst], vec![]) +} + +fn get_field(field: ImmOperand, dst: usize, obj: usize) -> RegInstr { + RegInstr::new(OpCode::GetField, field, vec![dst], vec![obj]) +} + +fn put_field(field: ImmOperand, value: usize, obj: usize) -> RegInstr { + RegInstr::new(OpCode::PutField, field, vec![], vec![value, obj]) +} + +fn define_field(field: ImmOperand, dst: usize, value: usize, obj: usize) -> RegInstr { + RegInstr::new(OpCode::DefineField, field, vec![dst], vec![value, obj]) +} + +/// A plain integer field name, as an operand. +fn int_field(n: u32) -> ImmOperand { + ImmOperand::Atom(AtomRef::TaggedInt(n)) +} diff --git a/compiler/src/flatten.rs b/compiler/src/flatten.rs index 6405a03..4f20a41 100644 --- a/compiler/src/flatten.rs +++ b/compiler/src/flatten.rs @@ -4,7 +4,7 @@ use crate::parse::{ Atom, AtomRef, ClosureKind, Function, Instr, JsString, JsValue, QuickJSBinary, atom_names, }; -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub enum CaptureFrom { /// `parent.var_defs[i]` ParentArg(usize), diff --git a/compiler/src/main.rs b/compiler/src/main.rs index 400bdcd..033f634 100644 --- a/compiler/src/main.rs +++ b/compiler/src/main.rs @@ -1,4 +1,5 @@ mod declass; +mod declosure; mod flatten; mod parse; mod regify; @@ -6,6 +7,7 @@ mod regify; use std::process::Command; use declass::declass; +use declosure::declosure; use flatten::flatten; use parse::QuickJSBinary; use regify::regify; @@ -41,6 +43,10 @@ fn main() { let declassed = declass(reg_fns, &atoms); println!("{declassed:#?}"); + println!("=== after declosure ==="); + let declosured = declosure(declassed); + println!("{declosured:#?}"); + println!("{atoms:#?}"); /*println!("=== after deprototype ==="); diff --git a/compiler/src/regify.rs b/compiler/src/regify.rs index c0ff3fa..b4b7e1e 100644 --- a/compiler/src/regify.rs +++ b/compiler/src/regify.rs @@ -1,10 +1,8 @@ -#![allow(non_snake_case)] - use std::fmt::Debug; use crate::{ flatten::{Capture, FlatFn}, - parse::{AtomRef, ImmOperand, Instr, JsValue, opcodes::OpCode}, + parse::{AtomRef, ImmOperand, JsValue, opcodes::OpCode}, }; pub struct RegInstr { @@ -51,7 +49,7 @@ impl RegFn { fn from_flat(flat_fn: FlatFn) -> Self { let mut stack = vec![]; let mut output = Vec::with_capacity(flat_fn.code.len()); - let mut next_val = flat_fn.var_count; + let mut next_val = flat_fn.var_count; // Reserve values for locals let mut fresh_val = || { let v = next_val; diff --git a/docs/declass_algo.txt b/docs/declass_algo.txt index e56d9e3..026e099 100644 --- a/docs/declass_algo.txt +++ b/docs/declass_algo.txt @@ -175,16 +175,29 @@ Swap, CallMethod, PutLoc1, Drop) still occur elsewhere for ordinary reasons. Only the specific class-pattern instances listed above are removed. +==================================================================== +UNSUPPORTED (the pass panics) +==================================================================== + + DefineClass AtomU8 { arg: 1 } derived class (extends) + + Prototype form would have to flatten the parent's prototype into the + child's and lower `super`. Neither is implemented, so declass panics + rather than emit a class that has silently lost its parent. + + ==================================================================== NOT TOUCHED (passed through unchanged) ==================================================================== - DefineClass AtomU8 { arg: 1 } derived class - DefineMethod AtomU8 { arg: 1 } getter - DefineMethod AtomU8 { arg: 2 } setter DefineMethodComputed ... computed method name DefineClassComputed ... computed class name + Known gap: a getter or setter (DefineMethod with a non-zero kind) is + NOT yet passed through. It is wrongly lowered as an ordinary method, + to a put_field, giving a data property instead of an accessor. It + should instead make the whole class pass through untouched. + ==================================================================== LEFT BEHIND (removed by a later pass) diff --git a/docs/declosure_algo.txt b/docs/declosure_algo.txt index 2886ecf..191a646 100644 --- a/docs/declosure_algo.txt +++ b/docs/declosure_algo.txt @@ -120,8 +120,12 @@ last argument, so it is args.last(). DETECT, abstractly: - plain call call | call0..3 (callee = last arg; not call_method, - call_constructor, or apply) + plain call call | call0..3 | tail_call (callee = last arg; not + call_method, call_constructor, + or apply) + + A tail call stays a tail call. It pushes nothing and returns, so turning it + into an ordinary call would leave a value behind and drop the return. outer, which in the example is: