First declosure impl

This commit is contained in:
2026-09-04 13:53:59 +00:00
parent af85244587
commit 617dbeb988
7 changed files with 376 additions and 11 deletions
+28 -1
View File
@@ -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<ClassSequence> {
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
)
};
+317
View File
@@ -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<RegFn>) -> Vec<RegFn> {
// 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<Vec<CaptureFrom>> = 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<CaptureFrom>]) -> 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<CaptureAccess> {
// 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<CaptureFrom>]) -> 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, &reg_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<usize> {
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, <original args>, 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))
}
+1 -1
View File
@@ -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),
+6
View File
@@ -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 ===");
+2 -4
View File
@@ -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;
+16 -3
View File
@@ -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)
+6 -2
View File
@@ -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: