New direction

This commit is contained in:
2026-09-03 20:20:58 +00:00
parent 92451e9cdc
commit af85244587
12 changed files with 1095 additions and 998 deletions
-336
View File
@@ -1,336 +0,0 @@
//! Transform: ES classes become prototype form.
//!
//! quickjs compiles the two OOP styles differently. A `class` is *declared*
//! with dedicated opcodes; a prototype chain is *computed* with ordinary
//! property and call opcodes. Prototype form is the lower of the two, so this
//! pass lowers classes into it, leaving one object model for everything
//! downstream.
//!
//! ctor, proto = define_class [template, parent] "Vec" #flags
//! proto' = define_method [method, proto] "len" #0
//!
//! becomes what the same code written with prototypes would produce:
//!
//! proto = object the prototype
//! proto' = define_field [proto, method] "len" an ordinary property
//! ctor = define_field [template, proto'] "prototype" hung off the ctor
//!
//! A function is a plain constant here (see `declosure`), so the constructor
//! is just the template constant that `push_const` already produced.
//!
//! A derived class starts its prototype as a COPY of the parent's, then
//! installs its own methods over it. That flattens the hierarchy where it is
//! declared, so no chain survives to be walked at run time:
//!
//! empty = object
//! parent_proto = get_field parent "prototype"
//! proto = copy_data_properties [empty, parent_proto]
//!
//! Runs BEFORE declosure, so the constructor it leaves behind is an ordinary
//! function that declosure then handles like any other.
//!
//! Known gaps:
//!
//! * **Instance field initialisers.** A class body may declare fields
//! (`x = 1`). quickjs compiles them into a synthesised function the
//! constructor captures by reference and calls on `this`. This pass does
//! not reconstruct that capture, so a class with instance fields silently
//! loses its initialisers. It is the same by-reference-capture problem as
//! the parent-writes-a-capture gap in `declosure`.
//! * **Getters and setters** (`define_method` with a non-zero kind) are left
//! alone; they need property descriptors.
//! * **The `_computed` forms** (`define_method_computed`,
//! `define_class_computed`) take the name off the stack, not as an atom
//! operand, and are left untouched.
//! * **Super / parent link.** A derived class records its parent constructor
//! in a synthetic `__parent` field (an integer name, so no atom is minted),
//! which `deprototype`'s `super` reads. Implicit-constructor synthesis only
//! fires when the parent class flows directly from its own `define_class`
//! in the same function.
//! * **Dead code.** The `push_const` that produced the constructor template
//! stays, now doubling as the constructor value. Nothing sweeps it yet.
use crate::{
parse::{AtomRef, ImmOperand, JsValue, opcodes::OpCode},
regify::{Fresh, RegFn, RegInstr, int_field},
};
/// Synthetic field recording a derived class's parent constructor, read by
/// `deprototype`'s `super` lowering. The high value keeps it clear of the
/// small positional fields the other passes use.
pub const PARENT_LINK: u32 = 0xFFFF_FFFF;
/// The `__parent` field name as an operand.
pub fn parent_link() -> ImmOperand {
int_field(PARENT_LINK)
}
/// The `"prototype"` field name, resolved to its predefined atom.
pub fn proto_atom() -> ImmOperand {
let index = crate::parse::atom_names::PREDEFINED_ATOMS
.iter()
.position(|&name| name == "prototype")
.expect("prototype is a predefined atom");
ImmOperand::Atom(AtomRef::Predefined(index as u32))
}
/// Lower every class in the program to prototype form.
pub fn declass(mut fns: Vec<RegFn>) -> Vec<RegFn> {
// Parameter counts, read before anything changes them.
let arity: Vec<usize> = fns.iter().map(|f| f.arg_count).collect();
// Filled in while lowering: every constructor's fn id, and each derived
// constructor paired with its parent's arity.
let mut ctors: Vec<usize> = Vec::new();
let mut derived_ctors: Vec<(usize, usize)> = Vec::new();
for f in &mut fns {
lower_classes_in(f, &arity, &mut ctors, &mut derived_ctors);
}
for id in ctors {
if let Some(f) = fns.get_mut(id) {
f.is_ctor = true;
}
}
// A derived class with no constructor written gets one, as if it had been.
for (id, parent_arity) in derived_ctors {
if let Some(f) = fns.get_mut(id) {
expand_implicit_ctor(f, parent_arity);
}
}
fns
}
/// A class whose prototype is still collecting methods. It is attached to its
/// constructor only once finished, so it never escapes half-built.
struct OpenClass {
/// The constructor value the rest of the code uses.
ctor: usize,
/// The constructor's function constant.
template: usize,
/// The latest version of the prototype object.
proto: usize,
derived: bool,
parent: usize,
}
/// Lower every class declared in one function.
fn lower_classes_in(
f: &mut RegFn,
arity: &[usize],
ctors: &mut Vec<usize>,
derived_ctors: &mut Vec<(usize, usize)>,
) {
let templates = function_templates(f);
let mut fresh = Fresh::for_fn(f);
// Constructor values seen so far, as (value, fn id), so a derived class
// declared later in this function can find its parent.
let mut ctor_of: Vec<(usize, usize)> = Vec::new();
let mut open: Vec<OpenClass> = Vec::new();
let code = std::mem::take(&mut f.code);
let mut out = Vec::with_capacity(code.len());
for instr in code {
match instr.op {
OpCode::DefineClass if instr.dsts.len() == 2 && instr.args.len() == 2 => {
let ImmOperand::AtomU8 { arg: flags, .. } = instr.imm else {
out.push(instr);
continue;
};
let ctor = instr.dsts[0];
let proto = instr.dsts[1];
let template = instr.args[0]; // popped first: it was on top of the stack
let parent = instr.args[1];
let derived = flags & 1 != 0;
let ctor_fn = templates.iter().find(|(v, _)| *v == template).map(|(_, id)| *id);
if derived {
// Flatten the hierarchy here: the prototype starts as a copy
// of the parent's, and this class's own methods go over it.
let empty = fresh.new_value();
let parent_proto = fresh.new_value();
out.push(RegInstr::new(OpCode::Object, ImmOperand::None, vec![empty], vec![]));
out.push(RegInstr::new(OpCode::GetField, proto_atom(), vec![parent_proto], vec![parent]));
out.push(RegInstr::new(
OpCode::CopyDataProperties,
ImmOperand::U8(0),
vec![proto],
vec![empty, parent_proto],
));
} else {
out.push(RegInstr::new(OpCode::Object, ImmOperand::None, vec![proto], vec![]));
}
if let Some(id) = ctor_fn {
// A derived class whose parent was declared here is paired
// with the parent's arity, for the implicit constructor.
let parent_fn = ctor_of.iter().find(|(v, _)| *v == parent).map(|(_, id)| *id);
if let (true, Some(parent_fn)) = (derived, parent_fn) {
derived_ctors.push((id, arity.get(parent_fn).copied().unwrap_or(0)));
}
ctors.push(id);
ctor_of.push((ctor, id));
}
open.push(OpenClass {
ctor,
template,
proto,
derived,
parent,
});
}
// A plain method is an ordinary property on the prototype. Kind 0
// only; getters and setters keep their opcode.
OpCode::DefineMethod if method_kind(&instr) == Some(0) && instr.args.len() == 2 => {
let ImmOperand::AtomU8 { atom: name, .. } = instr.imm else {
out.push(instr);
continue;
};
let newer_proto = instr.dsts[0];
let method = instr.args[0];
let proto = instr.args[1];
out.push(RegInstr::new(
OpCode::DefineField,
ImmOperand::Atom(name),
vec![newer_proto],
vec![proto, method],
));
// The class's prototype is now this newer version.
if let Some(class) = open.iter_mut().find(|c| c.proto == proto) {
class.proto = newer_proto;
}
}
_ => {
// Reading a constructor forces its prototype to be finished.
let mut i = 0;
while i < open.len() {
if instr.mentions(open[i].ctor) {
finish_class(open.remove(i), &mut out, &mut fresh);
} else {
i += 1;
}
}
out.push(instr);
}
}
}
// Whatever is still open finishes at the end of the function.
while let Some(class) = open.pop() {
finish_class(class, &mut out, &mut fresh);
}
f.code = out;
}
/// Hang the finished prototype off the constructor — that is what `.prototype`
/// is. `define_field` returns the object, so the constructor lands in `ctor`.
/// A derived class also records its parent in `__parent`, for `super`.
fn finish_class(class: OpenClass, out: &mut Vec<RegInstr>, fresh: &mut Fresh) {
let with_proto = if class.derived { fresh.new_value() } else { class.ctor };
out.push(RegInstr::new(
OpCode::DefineField,
proto_atom(),
vec![with_proto],
vec![class.template, class.proto],
));
if class.derived {
out.push(RegInstr::new(
OpCode::DefineField,
parent_link(),
vec![class.ctor],
vec![with_proto, class.parent],
));
}
}
/// `class Dog extends Animal {}` compiles to a constructor holding a single
/// `init_ctor`, which builds `this` by invoking the parent with the incoming
/// arguments. Our calling convention is fixed-arity, so we give the
/// constructor the parent's parameters and write out the `super(...)` it stood
/// for — after which it is an ordinary explicit constructor.
fn expand_implicit_ctor(f: &mut RegFn, parent_arity: usize) {
if !f.code.iter().any(|i| i.op == OpCode::InitCtor) {
return; // an explicit constructor: nothing to write
}
f.arg_count = parent_arity;
let mut fresh = Fresh::for_fn(f);
let code = std::mem::take(&mut f.code);
let mut out = Vec::with_capacity(code.len() + 3 + parent_arity);
for instr in code {
if instr.op != OpCode::InitCtor {
out.push(instr);
continue;
}
let this = instr.dsts[0];
// `this = super(a0 … an)`, spelled the way a written constructor is.
let running_fn = fresh.new_value();
let parent = fresh.new_value();
let new_target = fresh.new_value();
out.push(RegInstr::new(OpCode::SpecialObject, ImmOperand::U8(2), vec![running_fn], vec![]));
out.push(RegInstr::new(OpCode::GetSuper, ImmOperand::None, vec![parent], vec![running_fn]));
out.push(RegInstr::new(OpCode::Undefined, ImmOperand::None, vec![new_target], vec![]));
let mut forwarded = Vec::with_capacity(parent_arity);
for i in 0..parent_arity {
let arg = fresh.new_value();
out.push(RegInstr::new(OpCode::GetArg, ImmOperand::Arg(i as u16), vec![arg], vec![]));
forwarded.push(arg);
}
// In register form a call's args run in pop order: the last argument
// first, then new.target, then the callee.
let mut args = Vec::with_capacity(parent_arity + 2);
args.extend(forwarded.iter().rev().copied());
args.push(new_target);
args.push(parent);
out.push(RegInstr::new(
OpCode::CallConstructor,
ImmOperand::Npop(parent_arity as u16),
vec![this],
args,
));
}
f.code = out;
}
// ─── helpers ───────────────────────────────────────────────────────────────
/// Values holding a nested-function constant, as (value, fn id). A function
/// loads with `push_const`, or with `fclosure` until declosure lowers it.
fn function_templates(f: &RegFn) -> Vec<(usize, usize)> {
let mut templates = Vec::new();
for instr in &f.code {
let loads_function = matches!(
instr.op,
OpCode::PushConst | OpCode::PushConst8 | OpCode::Fclosure | OpCode::Fclosure8
);
let (ImmOperand::Const(index), Some(&value)) = (instr.imm, instr.dsts.first()) else {
continue;
};
if !loads_function {
continue;
}
if let Some(JsValue::FnById(id)) = f.cpool.get(index as usize) {
templates.push((value, *id));
}
}
templates
}
/// The method kind carried by a `define_method` (0 = plain method).
fn method_kind(instr: &RegInstr) -> Option<u8> {
match instr.imm {
ImmOperand::AtomU8 { arg, .. } => Some(arg),
_ => None,
}
}
+31
View File
@@ -0,0 +1,31 @@
//! declass: ES classes become prototype form, one algo step per file.
//!
//! The steps follow declass_algo.txt:
//!
//! step_1 rewrite how a class is built (per function).
//! step_2 splice the field initialiser into the constructor.
//! step_3 fix the constructor header: rename, drop the capture (to come).
mod step_1;
mod step_2;
use crate::{parse::Atom, regify::RegFn};
/// A class's constructor and its hoisted field initialiser, as fn ids into the
/// flat function list. Read by step 1, consumed by step 2.
#[derive(Clone, Copy)]
struct FieldInit {
ctor: usize,
fields: usize,
}
pub fn declass(reg_fns: Vec<RegFn>, _atoms: &[Atom]) -> Vec<RegFn> {
// Read the constructor <-> field-initialiser links before step 1 rewrites
// the class builds that carry them.
let field_inits = step_1::field_init_links(&reg_fns);
let mut reg_fns: Vec<RegFn> = reg_fns.into_iter().map(step_1::rewrite).collect();
step_2::splice(&mut reg_fns, &field_inits);
reg_fns
}
+274
View File
@@ -0,0 +1,274 @@
//! Step 1 of declass: rewrite how a class is built, in prototype form.
//!
//! A class-build cluster (declass_algo.txt, step 1):
//!
//! push_const8 #ctor ; define_class @Name
//! ( fclosure8 #m ; define_method @meth )*
//! ( fclosure8 #fields ; set_home_object ; put_loc ; drop )?
//!
//! becomes `fclosure8 #ctor` followed, per method, by
//! `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.
use std::range::Range;
use crate::{
parse::{AtomRef, ImmOperand, JsValue, atom_names::PREDEFINED_ATOMS, opcodes::OpCode},
regify::{RegFn, RegInstr},
};
use super::FieldInit;
/// Read each constructor <-> field-initialiser link off the class builds, as
/// fn ids. Run before `rewrite`, which drops the instructions carrying them.
pub(super) fn field_init_links(fns: &[RegFn]) -> Vec<FieldInit> {
let mut links = vec![];
for f in fns {
for seq in find_class_seqs(&f.code) {
if let Some(link) = seq.field_init(&f.code, &f.cpool) {
links.push(link);
}
}
}
links
}
/// Rewrite every class build in one function to prototype form.
pub(super) fn rewrite(mut reg_fn: RegFn) -> RegFn {
let seqs = find_class_seqs(&reg_fn.code);
// Move the code out of reg_fn so we can consume it while still calling
// reg_fn.fresh_val(); the empty Vec left behind costs nothing.
let code = std::mem::take(&mut reg_fn.code);
let total = code.len();
let mut out = Vec::with_capacity(total);
let mut instrs = code.into_iter();
let mut seqs = seqs.iter();
let mut next_seq = seqs.next();
let mut pos = 0;
while pos < total {
match next_seq {
Some(seq) if seq.range.start == pos => {
let adv = seq.range.end - seq.range.start;
let matched: Vec<RegInstr> = instrs.by_ref().take(adv).collect();
seq.emit_prototype(&mut reg_fn, &mut out, &matched);
pos += adv;
next_seq = seqs.next();
}
_ => {
out.push(instrs.next().unwrap());
pos += 1;
}
}
}
reg_fn.code = out;
reg_fn
}
struct ClassSequence {
range: Range<usize>,
num_methods: usize,
}
impl ClassSequence {
/// Emit the prototype form, reading operands straight out of `matched` --
/// the class sequence's own instructions.
fn emit_prototype(&self, reg_fn: &mut RegFn, out: &mut Vec<RegInstr>, matched: &[RegInstr]) {
// push_const8 #ctor ; define_class -> fclosure8 #ctor (constructor kept)
let (ctor_const, ctor_val) = self.ctor(matched);
out.push(fclosure8(ctor_const, ctor_val));
// each ( fclosure8 #m ; define_method @meth ) -> get_field + put_field
for k in 0..self.num_methods {
let (method_const, method_val, meth) = self.method(matched, k);
out.push(fclosure8(method_const, method_val));
let proto = reg_fn.fresh_val();
out.push(get_field(proto_atom(), proto, ctor_val));
out.push(put_field(ImmOperand::Atom(meth), method_val, proto));
}
// the fclosure8 #fields ; set_home_object ; put_loc ; drop tail is dropped.
}
/// The constructor's constant and value: push_const8 #ctor ; define_class.
fn ctor(&self, matched: &[RegInstr]) -> (u32, usize) {
(as_const(matched[0].imm), matched[1].dsts[0])
}
/// The k-th method's constant, closure value, and name; each method pair
/// sits after the two constructor instructions.
fn method(&self, matched: &[RegInstr], k: usize) -> (u32, usize, AtomRef) {
let fclosure = &matched[2 + 2 * k];
let define_method = &matched[3 + 2 * k];
(
as_const(fclosure.imm),
fclosure.dsts[0],
atom_of(define_method.imm),
)
}
/// The constructor and field-initialiser fn ids, if the class has a
/// field-initialiser tail. `code` is the whole enclosing function, so the
/// range indexes it directly.
fn field_init(&self, code: &[RegInstr], cpool: &[JsValue]) -> Option<FieldInit> {
let base = 2 + 2 * self.num_methods; // constructor + methods, before the tail
if self.range.end - self.range.start == base {
return None; // no field-initialiser tail
}
let ctor_const = as_const(code[self.range.start].imm);
let fields_const = as_const(code[self.range.start + base].imm);
Some(FieldInit {
ctor: fn_id(cpool, ctor_const),
fields: fn_id(cpool, fields_const),
})
}
}
fn find_class_seqs(mut ins: &[RegInstr]) -> Vec<ClassSequence> {
let mut seqs = vec![];
let mut offset = 0;
while !ins.is_empty() {
match match_class_seq(ins) {
Ok((rest, num_methods)) => {
let adv = ins.len() - rest.len();
seqs.push(ClassSequence {
range: Range {
start: offset,
end: offset + adv,
},
num_methods,
});
offset += adv;
ins = rest;
}
Err(_) => {
offset += 1;
ins = &ins[1..];
}
}
}
seqs
}
// ── the grammar, as matchers over the instruction stream ───────────────────
fn match_class_seq(ins: &[RegInstr]) -> Result<(&[RegInstr], usize), &[RegInstr]> {
let ins = match_one(ins, |i| i.op == OpCode::PushConst8)?;
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);
Ok((ins, num_methods))
}
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)?;
Ok(ins)
}
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
)
};
let ins = match_one(ins, |i| i.op == OpCode::Fclosure8)?;
let ins = match_one(ins, |i| i.op == OpCode::SetHomeObject)?;
let ins = match_one(ins, |i| is_put_loc(i.op))?;
let ins = match_one(ins, |i| i.op == OpCode::Drop)?;
Ok(ins)
}
// ── matcher primitives ─────────────────────────────────────────────────────
fn match_one(i: &[RegInstr], f: impl Fn(&RegInstr) -> bool) -> Result<&[RegInstr], &[RegInstr]> {
match i.first() {
Some(instr) if f(instr) => Ok(&i[1..]),
_ => Err(i),
}
}
fn match_many(
mut i: &[RegInstr],
f: impl Fn(&[RegInstr]) -> Result<&[RegInstr], &[RegInstr]>,
) -> (&[RegInstr], usize) {
let mut n = 0;
while let Ok(rest) = f(i) {
n += 1;
i = rest;
}
(i, n)
}
fn match_opt<'a>(
i: &'a [RegInstr],
f: impl Fn(&'a [RegInstr]) -> Result<&'a [RegInstr], &'a [RegInstr]>,
) -> &'a [RegInstr] {
match f(i) {
Ok(rest) => rest,
Err(_) => i,
}
}
// ── emitted instructions ───────────────────────────────────────────────────
fn fclosure8(const_index: u32, dst: usize) -> RegInstr {
RegInstr::new(
OpCode::Fclosure8,
ImmOperand::Const(const_index),
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])
}
// ── operand helpers ────────────────────────────────────────────────────────
fn atom_of(imm: ImmOperand) -> AtomRef {
match imm {
ImmOperand::AtomU8 { atom, .. } => atom,
_ => unreachable!("expected an atom operand"),
}
}
fn as_const(imm: ImmOperand) -> u32 {
match imm {
ImmOperand::Const(c) => c,
_ => unreachable!("expected a constant operand"),
}
}
/// The function id a constant-pool entry points at.
fn fn_id(cpool: &[JsValue], const_index: u32) -> usize {
match cpool[const_index as usize] {
JsValue::FnById(id) => id,
_ => unreachable!("class constant is not a function"),
}
}
/// The `"prototype"` field name as a predefined-atom operand.
fn proto_atom() -> ImmOperand {
let i = PREDEFINED_ATOMS
.iter()
.position(|&n| n == "prototype")
.unwrap();
ImmOperand::Atom(AtomRef::Predefined(i as u32))
}
+117
View File
@@ -0,0 +1,117 @@
//! Step 2 of declass: splice the field initialiser into the constructor.
//!
//! A class constructor does not set its own fields; quickjs hoisted them into a
//! separate initialiser function that the constructor reaches through the
//! captured <class_fields_init> and runs with a call (declass_algo.txt, step 2):
//!
//! get_var_ref_check <class_fields_init> ; dup ; if_false
//! ; get_loc ; swap ; call_method
//!
//! This step takes the initialiser's field writes -- its body minus the
//! this/home-object prologue and the trailing return -- and splices them into
//! the constructor in place of that call, renumbered onto fresh value ids. The
//! initialiser function is left unreferenced.
//!
//! Deficiency, left for step 3: a class with NO instance fields compiles
//! `<class_fields_init>` to `Undefined` rather than a closure, so there is no
//! initialiser to splice and `field_init_links` records no link. Its
//! constructor keeps the call block above, which the `if_false` skips at run
//! time -- a harmless no-op. Step 3 must strip that block when it drops the
//! `<class_fields_init>` capture, or the `get_var_ref_check` would dangle.
use super::FieldInit;
use crate::{
parse::{ImmOperand, opcodes::OpCode},
regify::{RegFn, RegInstr},
};
/// The name quickjs gives the hoisted field initialiser.
const FIELDS_INIT: &str = "<class_fields_init>";
pub(super) fn splice(fns: &mut [RegFn], field_inits: &[FieldInit]) {
for &FieldInit { ctor, fields } in field_inits {
splice_one(fns, ctor, fields);
}
}
fn splice_one(fns: &mut [RegFn], ctor: usize, fields: usize) {
// Pull the field writes out of the initialiser (a separate function), then
// hand them to the constructor.
let writes = field_writes(&fns[fields]);
let ctor_fn = &mut fns[ctor];
let Some((start, end)) = call_span(ctor_fn) else {
return;
};
let writes = renumber(writes, ctor_fn);
ctor_fn.code.splice(start..=end, writes);
}
/// The initialiser's field writes: its body minus the this/home-object prologue
/// and the trailing return. They reach `this` through get_loc0, which is `this`
/// in the constructor too, so they carry over unchanged.
fn field_writes(init: &RegFn) -> Vec<RegInstr> {
init.code
.iter()
.filter(|i| !is_prologue(i.op))
.map(|i| RegInstr::new(i.op, i.imm, i.dsts.clone(), i.args.clone()))
.collect()
}
/// Renumber the writes' value ids onto fresh ids in the constructor. Each write
/// only uses values it or an earlier write produced, so a flat map suffices.
fn renumber(mut writes: Vec<RegInstr>, ctor_fn: &mut RegFn) -> Vec<RegInstr> {
let hi = writes
.iter()
.flat_map(|i| i.dsts.iter().chain(&i.args))
.copied()
.max()
.unwrap_or(0);
let mut map = vec![usize::MAX; hi + 1];
for instr in &mut writes {
for arg in &mut instr.args {
*arg = map[*arg];
}
for dst in &mut instr.dsts {
let fresh = ctor_fn.fresh_val();
map[*dst] = fresh;
*dst = fresh;
}
}
writes
}
/// The call block `get_var_ref_check <class_fields_init> .. call_method`, as an
/// inclusive `start..=end` into the constructor's code.
fn call_span(ctor: &RegFn) -> Option<(usize, usize)> {
let start = ctor.code.iter().position(|i| reads_fields_init(i, ctor))?;
let end = ctor.code[start..]
.iter()
.position(|i| matches!(i.op, OpCode::CallMethod | OpCode::TailCallMethod))?;
Some((start, start + end))
}
/// True for `get_var_ref_check` on the `<class_fields_init>` capture.
fn reads_fields_init(i: &RegInstr, ctor: &RegFn) -> bool {
let ImmOperand::VarRef(k) = i.imm else {
return false;
};
i.op == OpCode::GetVarRefCheck && ctor.captures[k as usize].name == FIELDS_INIT
}
/// The initialiser prologue and terminator, dropped when splicing.
fn is_prologue(op: OpCode) -> bool {
matches!(
op,
OpCode::SpecialObject
| OpCode::PushThis
| OpCode::PutLoc0
| OpCode::PutLoc1
| OpCode::PutLoc2
| OpCode::PutLoc3
| OpCode::Goto8
| OpCode::Goto
| OpCode::ReturnUndef
)
}
-252
View File
@@ -1,252 +0,0 @@
//! Transform: closures become plain objects.
//!
//! A closure becomes an object holding its function plus its captured
//! variables. Field 0 is the function; fields 1.. are the captures, in
//! capture-index order. The object doubles as the environment, so there is no
//! second env value to track.
//!
//! 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 — a pointer 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 materialize 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`.** They carry a receiver, so
//! they belong to the class passes; step 3 leaves them untouched. Same for
//! `apply`.
use crate::{
flatten::CaptureFrom,
parse::{ImmOperand, JsValue, opcodes::OpCode},
regify::{Fresh, RegFn, RegInstr, as_plain_call, int_field, is_plain_call},
};
/// Which capture (closure variable) an instruction touches, and how.
enum CaptureAccess {
Read(usize),
Write(usize),
}
pub fn declosure(mut fns: Vec<RegFn>) -> Vec<RegFn> {
// Where each function's captures come from, per fn id. Snapshotted first,
// because step 1 clears the capture lists this is read from.
let capture_sources: Vec<Vec<CaptureFrom>> = fns
.iter()
.map(|f| f.captures.iter().map(|c| c.from).collect())
.collect();
for f in &mut fns {
let mut fresh = Fresh::for_fn(f);
take_env_param(f, &mut fresh);
build_closure_objects(f, &capture_sources, &mut fresh);
route_calls_through_object(f, &mut fresh);
}
fns
}
// ─── 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(f: &mut RegFn, fresh: &mut Fresh) {
let env_param = f.arg_count;
f.arg_count += 1;
let env = fresh.new_value();
let code = std::mem::take(&mut f.code);
let touches_captures = code.iter().any(|i| capture_access(i).is_some());
let mut out = Vec::with_capacity(code.len() + 1);
// Load the env once at entry — only if a capture is actually accessed.
if touches_captures {
let load_env = ImmOperand::Arg(env_param as u16);
out.push(RegInstr::new(OpCode::GetArg, load_env, vec![env], vec![]));
}
for instr in code {
match capture_access(&instr) {
// A read keeps its destination, so downstream uses do not change.
Some(CaptureAccess::Read(k)) => {
let field = int_field(k as u32 + 1);
out.push(RegInstr::new(OpCode::GetField, field, instr.dsts, vec![env]));
}
Some(CaptureAccess::Write(k)) => {
let field = int_field(k as u32 + 1);
let mut args = vec![env];
args.extend(instr.args); // the value being stored
out.push(RegInstr::new(OpCode::PutField, field, vec![], args));
}
None => out.push(instr),
}
}
f.code = out;
f.captures.clear();
}
/// Which capture an instruction reads or writes, if any.
fn capture_access(instr: &RegInstr) -> Option<CaptureAccess> {
use OpCode::*;
let slot_in_operand = || match instr.imm {
ImmOperand::VarRef(k) => k as usize,
_ => 0,
};
Some(match instr.op {
GetVarRef | GetVarRefCheck => CaptureAccess::Read(slot_in_operand()),
GetVarRef0 => CaptureAccess::Read(0),
GetVarRef1 => CaptureAccess::Read(1),
GetVarRef2 => CaptureAccess::Read(2),
GetVarRef3 => CaptureAccess::Read(3),
PutVarRef | PutVarRefCheck | PutVarRefCheckInit => CaptureAccess::Write(slot_in_operand()),
PutVarRef0 => CaptureAccess::Write(0),
PutVarRef1 => CaptureAccess::Write(1),
PutVarRef2 => CaptureAccess::Write(2),
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(f: &mut RegFn, capture_sources: &[Vec<CaptureFrom>], fresh: &mut Fresh) {
let code = std::mem::take(&mut f.code);
let mut out = Vec::with_capacity(code.len());
for instr in code {
let (Some(child), Some(&closure)) = (closure_child(&instr, &f.cpool), instr.dsts.first())
else {
out.push(instr);
continue;
};
// 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 = fresh.new_value();
out.push(RegInstr::new(load, instr.imm, vec![func], vec![]));
let mut obj = fresh.new_value();
out.push(RegInstr::new(OpCode::Object, ImmOperand::None, vec![obj], vec![]));
// Each `define_field` returns the object, so the stores chain. The
// LAST store must land in `closure` — the value the rest of the code
// already uses for this closure.
let captures: &[CaptureFrom] = capture_sources.get(child).map(Vec::as_slice).unwrap_or(&[]);
// Store the function in field 0.
let is_last = captures.is_empty();
let result = if is_last { closure } else { fresh.new_value() };
out.push(RegInstr::new(OpCode::DefineField, int_field(0), vec![result], vec![obj, func]));
obj = result;
// Store each capture in field k+1.
for (k, source) in captures.iter().enumerate() {
let value = fresh.new_value();
out.push(load_capture(*source, value));
let is_last = k + 1 == captures.len();
let result = if is_last { closure } else { fresh.new_value() };
let field = int_field(k as u32 + 1);
out.push(RegInstr::new(OpCode::DefineField, field, vec![result], vec![obj, value]));
obj = result;
}
}
f.code = out;
}
/// The nested function an `fclosure` instantiates, as a fn id.
fn closure_child(instr: &RegInstr, cpool: &[JsValue]) -> Option<usize> {
if !matches!(instr.op, OpCode::Fclosure | OpCode::Fclosure8) {
return None;
}
let ImmOperand::Const(index) = instr.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) => {
RegInstr::new(OpCode::GetLoc, ImmOperand::Loc(slot as u16), vec![into], vec![])
}
CaptureFrom::ParentArg(slot) => {
RegInstr::new(OpCode::GetArg, ImmOperand::Arg(slot as u16), vec![into], vec![])
}
_ => 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(f: &mut RegFn, fresh: &mut Fresh) {
let code = std::mem::take(&mut f.code);
let mut out = Vec::with_capacity(code.len());
for instr in code {
if !is_plain_call(instr.op) || instr.args.is_empty() {
out.push(instr);
continue;
}
let closure = *instr.args.last().unwrap(); // the callee is always last
let func = fresh.new_value();
out.push(RegInstr::new(OpCode::GetField, int_field(0), vec![func], vec![closure]));
let call_args = &instr.args[..instr.args.len() - 1];
let mut args = Vec::with_capacity(instr.args.len() + 1);
args.push(func);
args.extend_from_slice(call_args);
args.push(closure); // the env, last
out.push(RegInstr::new(as_plain_call(instr.op), instr.imm, instr.dsts, args));
}
f.code = out;
}
-274
View File
@@ -1,274 +0,0 @@
//! Transform: prototypes and `this` become plain objects and parameters.
//!
//! After `declass`, every OOP shape is prototype form: a constructor object
//! with a `prototype` field, methods hung off that prototype, and method
//! bodies reading an implicit `this`. This pass removes both remaining pieces
//! of machinery — the implicit receiver, and the prototype chain.
//!
//! Three steps:
//!
//! 1. `take_self_param` — every function takes `self` as its last
//! parameter, and `this` (`push_this`) reads
//! it. Applied uniformly — a function that
//! never mentions `this` still takes one —
//! because a call site cannot know whether
//! its callee does.
//! 2. `build_and_return_object` — constructors only. `new C(a)` makes a
//! fresh object carrying the prototype's
//! properties (`copy_data_properties`, the
//! opcode behind `{...obj}`) and runs the
//! constructor over it; the object is the
//! result. Field writes on `this` thread —
//! each `put_field` becomes a `define_field`
//! yielding the next version — and the
//! constructor returns the last. The instance
//! owns its methods, so `get_field` finds one
//! exactly as it finds a field, and no chain
//! is walked at run time.
//! 3. `pass_receivers_explicitly` — `o.m(a)` passes `o` as an explicit
//! argument rather than binding it implicitly.
//!
//! Runs BETWEEN declass and declosure, so parameters end up `(args…, self,
//! env)`: this pass appends `self`, declosure appends `env` last, and each call
//! keeps its callee last so declosure can find it.
//!
//! Divergence from the closure-IR original: there a constructor read its
//! prototype off its env, because the env WAS the `{fn, prototype}` object.
//! Here a function is a plain constant, so the constructor has no env; it reads
//! the running function with `special_object #2` — which is the constructor
//! object `declass` built — and takes `prototype` off that.
//!
//! Known gaps:
//!
//! * A field written inside a branch is not threaded: the merge would need a
//! phi, and a linear pass cannot introduce one.
//! * `check_ctor_return` is not modelled: a constructor that explicitly
//! returns an object should yield it instead of the built one.
//! * `put_super_value` is untouched, so writing through `super` is not
//! lowered. `super(...)` is handled: it runs the parent constructor and the
//! object it returns becomes `this`.
//! * `apply` and the `_computed` call forms are left alone.
use crate::{
declass::{parent_link, proto_atom},
parse::{ImmOperand, opcodes::OpCode},
regify::{Fresh, RegFn, RegInstr, as_plain_call, is_plain_call},
};
/// Lower prototypes and `this` across the program.
pub fn deprototype(mut fns: Vec<RegFn>) -> Vec<RegFn> {
for f in &mut fns {
let mut fresh = Fresh::for_fn(f);
let this = take_self_param(f, &mut fresh);
if f.is_ctor {
build_and_return_object(f, this, &mut fresh);
}
pass_receivers_explicitly(f, &mut fresh);
}
fns
}
// ─── 1. `this` becomes a parameter ─────────────────────────────────────────
/// Append `self` as the last parameter, then point every `push_this` at it.
/// Returns the value `this` now lives in — `None` if the function never reads
/// it, in which case nothing is loaded.
fn take_self_param(f: &mut RegFn, fresh: &mut Fresh) -> Option<usize> {
let self_param = f.arg_count;
f.arg_count += 1;
// The values `push_this` produced.
let this_values: Vec<usize> = f
.code
.iter()
.filter(|i| i.op == OpCode::PushThis)
.flat_map(|i| i.dsts.iter().copied())
.collect();
if this_values.is_empty() {
return None;
}
// Load `self` once at entry; every `push_this` read points at that instead.
let this = fresh.new_value();
let code = std::mem::take(&mut f.code);
let mut out = Vec::with_capacity(code.len() + 1);
let load_self = ImmOperand::Arg(self_param as u16);
out.push(RegInstr::new(OpCode::GetArg, load_self, vec![this], vec![]));
for mut instr in code {
if instr.op == OpCode::PushThis {
continue;
}
for v in instr.args.iter_mut().chain(instr.dsts.iter_mut()) {
if this_values.contains(v) {
*v = this;
}
}
out.push(instr);
}
f.code = out;
Some(this)
}
// ─── 2. a constructor produces its object ──────────────────────────────────
/// Rewrite a constructor from "fill the object you were handed" to "build one
/// and return it". A base constructor starts from a fresh object carrying the
/// prototype's properties, read off the running function (the constructor
/// object). A derived one starts from whatever `super(...)` returns.
fn build_and_return_object(f: &mut RegFn, this: Option<usize>, fresh: &mut Fresh) {
let derived = f.code.iter().any(|i| i.op == OpCode::GetSuper);
let code = std::mem::take(&mut f.code);
let mut out = Vec::with_capacity(code.len() + 4);
// The current version of the object under construction. A derived class
// has none until `super(...)` returns one.
let mut obj = this;
if !derived {
let running_fn = fresh.new_value();
let proto = fresh.new_value();
let empty = fresh.new_value();
let built = fresh.new_value();
out.push(RegInstr::new(OpCode::SpecialObject, ImmOperand::U8(2), vec![running_fn], vec![]));
out.push(RegInstr::new(OpCode::GetField, proto_atom(), vec![proto], vec![running_fn]));
out.push(RegInstr::new(OpCode::Object, ImmOperand::None, vec![empty], vec![]));
out.push(RegInstr::new(
OpCode::CopyDataProperties,
ImmOperand::U8(0),
vec![built],
vec![empty, proto],
));
obj = Some(built);
}
for mut instr in code {
// Reads of `this` see the current version of the object. Only uses are
// repointed: `self`'s own load keeps its (now dead) destination.
if let (Some(this), Some(current)) = (this, obj) {
for v in instr.args.iter_mut() {
if *v == this {
*v = current;
}
}
}
match instr.op {
// `this.f = v` becomes construction: the next version of the object.
OpCode::PutField if instr.args.len() == 2 && obj == Some(instr.args[1]) => {
let value = instr.args[0];
let current = instr.args[1];
let newer = fresh.new_value();
out.push(RegInstr::new(OpCode::DefineField, instr.imm, vec![newer], vec![current, value]));
obj = Some(newer);
}
// `super(...)` produced the object; continue from its result.
OpCode::CallConstructor if derived && !instr.dsts.is_empty() => {
obj = Some(instr.dsts[0]);
out.push(instr);
}
// The constructor returns the object it built.
OpCode::Return | OpCode::ReturnUndef if obj.is_some() => {
out.push(RegInstr::new(OpCode::Return, ImmOperand::None, vec![], vec![obj.unwrap()]));
}
_ => out.push(instr),
}
}
f.code = out;
}
// ─── 3. calls carry their receiver ─────────────────────────────────────────
/// Every call passes its receiver as an explicit argument, slotted in just
/// before the callee.
fn pass_receivers_explicitly(f: &mut RegFn, fresh: &mut Fresh) {
let code = std::mem::take(&mut f.code);
let mut out = Vec::with_capacity(code.len());
// `get_field2` also keeps the object it read from, under a second value.
// That echo is just the object; recorded here and substituted at the end.
let mut receiver_alias: Vec<(usize, usize)> = Vec::new();
for instr in code {
match instr.op {
// `new` is gone, so the guard against calling without it goes too.
OpCode::CheckCtor => {}
// `super` is the parent constructor, recorded by declass as a
// field on the constructor object.
OpCode::GetSuper if instr.args.len() == 1 && !instr.dsts.is_empty() => {
let read_parent = RegInstr::new(OpCode::GetField, parent_link(), vec![instr.dsts[0]], vec![instr.args[0]]);
out.push(read_parent);
}
// `o.m(a)` -> `m(a, o)`. Register args are `[a.., m, o]`.
OpCode::CallMethod | OpCode::TailCallMethod if instr.args.len() >= 2 => {
let n = instr.args.len();
let receiver = instr.args[n - 1];
let callee = instr.args[n - 2];
let args = args_with_self(&instr.args[..n - 2], receiver, callee);
out.push(RegInstr::new(as_plain_call(instr.op), instr.imm, instr.dsts, args));
}
// `new C(a)`: the constructor builds and returns the object now, so
// this is a plain call yielding it. `self` is undefined; the value
// dropped is new.target. Register args are `[a.., new.target, C]`.
OpCode::CallConstructor if instr.args.len() >= 2 => {
let n = instr.args.len();
let callee = instr.args[n - 1];
let undefined = push_undefined(&mut out, fresh);
let args = args_with_self(&instr.args[..n - 2], undefined, callee);
out.push(RegInstr::new(OpCode::Call, instr.imm, instr.dsts, args));
}
// `get_field2` kept the object under a second value. The object is
// an explicit argument now, so that copy is just the object, and
// this becomes an ordinary read.
OpCode::GetField2 if instr.dsts.len() == 2 && instr.args.len() == 1 => {
receiver_alias.push((instr.dsts[0], instr.args[0]));
out.push(RegInstr::new(OpCode::GetField, instr.imm, vec![instr.dsts[1]], vec![instr.args[0]]));
}
// A plain call has no receiver, so `this` is undefined.
op if is_plain_call(op) && !instr.args.is_empty() => {
let n = instr.args.len();
let callee = instr.args[n - 1];
let undefined = push_undefined(&mut out, fresh);
let args = args_with_self(&instr.args[..n - 1], undefined, callee);
out.push(RegInstr::new(as_plain_call(op), instr.imm, instr.dsts, args));
}
_ => out.push(instr),
}
}
// Point the echoed receivers at the object itself.
if !receiver_alias.is_empty() {
for instr in &mut out {
for v in instr.args.iter_mut().chain(instr.dsts.iter_mut()) {
if let Some((_, object)) = receiver_alias.iter().find(|(echo, _)| echo == v) {
*v = *object;
}
}
}
}
f.code = out;
}
/// The argument list of a call with `self` slotted in before the callee. The
/// callee must stay last so declosure can find it: `[args.., self, callee]`.
fn args_with_self(args: &[usize], self_val: usize, callee: usize) -> Vec<usize> {
let mut all = Vec::with_capacity(args.len() + 2);
all.extend_from_slice(args);
all.push(self_val);
all.push(callee);
all
}
/// Emit `undefined` and return the value holding it.
fn push_undefined(out: &mut Vec<RegInstr>, fresh: &mut Fresh) -> usize {
let v = fresh.new_value();
out.push(RegInstr::new(OpCode::Undefined, ImmOperand::None, vec![v], vec![]));
v
}
+4 -2
View File
@@ -4,7 +4,7 @@ use crate::parse::{
Atom, AtomRef, ClosureKind, Function, Instr, JsString, JsValue, QuickJSBinary, atom_names,
};
#[derive(Debug, Clone, Copy)]
#[derive(Debug)]
pub enum CaptureFrom {
/// `parent.var_defs[i]`
ParentArg(usize),
@@ -31,7 +31,8 @@ pub struct Capture {
pub struct FlatFn {
pub name: String,
pub arg_count: usize,
pub var_count: usize, // num locals
pub var_count: usize, // num locals
pub value_names: Vec<AtomRef>, // adds names to args and locals, one for each arg, then one for each local, in one vec
pub captures: Vec<Capture>,
pub cpool: Vec<JsValue>,
pub code: Vec<Instr>,
@@ -71,6 +72,7 @@ fn walk(flat_fns: &mut Vec<FlatFn>, func: Function, atoms: &[Atom]) -> usize {
name,
arg_count: func.arg_count,
var_count: func.var_count,
value_names: func.var_defs.iter().map(|v| v.name).collect(),
captures,
cpool: Vec::new(),
code: func.byte_code,
+8 -13
View File
@@ -1,6 +1,4 @@
mod declass;
mod declosure;
mod deprototype;
mod flatten;
mod parse;
mod regify;
@@ -8,8 +6,6 @@ mod regify;
use std::process::Command;
use declass::declass;
use declosure::declosure;
use deprototype::deprototype;
use flatten::flatten;
use parse::QuickJSBinary;
use regify::regify;
@@ -35,24 +31,23 @@ fn main() {
let (_, parsed) = QuickJSBinary::parse(&bin).unwrap();
let (flat_fns, atoms) = flatten(parsed);
println!("{atoms:#?}");
let reg_fns = regify(flat_fns);
println!("{flat_fns:#?}");
println!("=== after regify ===");
let reg_fns = regify(flat_fns);
println!("{reg_fns:#?}");
let declassed = declass(reg_fns);
println!("=== after declass ===");
let declassed = declass(reg_fns, &atoms);
println!("{declassed:#?}");
let deprototyped = deprototype(declassed);
println!("{atoms:#?}");
println!("=== after deprototype ===");
/*println!("=== after deprototype ===");
let deprototyped = deprototype(declassed);
println!("{deprototyped:#?}");
let declosured = declosure(deprototyped);
println!("=== after declosure ===");
println!("{declosured:#?}");
let declosured = declosure(deprototyped);
println!("{declosured:#?}");*/
}
+71 -121
View File
@@ -14,6 +14,17 @@ pub struct RegInstr {
pub args: Vec<usize>,
}
impl RegInstr {
pub fn new(op: OpCode, imm: ImmOperand, dsts: Vec<usize>, args: Vec<usize>) -> Self {
Self {
dsts,
op,
imm,
args,
}
}
}
impl Debug for RegInstr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
@@ -24,148 +35,87 @@ impl Debug for RegInstr {
}
}
/// A `FlatFn` with its stack bytecode lowered to register code.
#[derive(Debug)]
pub struct RegFn {
pub name: String,
pub arg_count: usize,
pub var_count: usize,
pub value_names: Vec<AtomRef>,
pub captures: Vec<Capture>,
pub cpool: Vec<JsValue>,
pub code: Vec<RegInstr>,
/// Set by `declass` when this function is a class constructor.
pub is_ctor: bool,
next_val: usize,
}
fn regify_walk(code: &[Instr], var_count: usize) -> Vec<RegInstr> {
let mut stack = vec![];
let mut output = vec![];
let mut next_val = var_count;
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 fresh_val = || {
let v = next_val;
next_val += 1;
v
};
for instr in code {
let mut reg_instr = RegInstr {
dsts: vec![],
op: instr.op,
imm: instr.imm_operands,
args: vec![],
let mut fresh_val = || {
let v = next_val;
next_val += 1;
v
};
let extra_pop = match instr.op {
OpCode::Call0 => 0,
OpCode::Call1 => 1,
OpCode::Call2 => 2,
OpCode::Call3 => 3,
_ => match instr.imm_operands {
ImmOperand::Npop(npop) => npop as usize,
ImmOperand::NpopU16 { npop, .. } => npop as usize,
_ => 0,
},
};
for instr in flat_fn.code {
let mut reg_instr = RegInstr {
dsts: vec![],
op: instr.op,
imm: instr.imm_operands,
args: vec![],
};
let info = instr.op.info();
let n_pop = info.n_pop as usize + extra_pop;
let n_push = info.n_push as usize;
let extra_pop = match instr.op {
OpCode::Call0 => 0,
OpCode::Call1 => 1,
OpCode::Call2 => 2,
OpCode::Call3 => 3,
_ => match instr.imm_operands {
ImmOperand::Npop(npop) => npop as usize,
ImmOperand::NpopU16 { npop, .. } => npop as usize,
_ => 0,
},
};
for _ in 0..n_pop {
let v = stack.pop().unwrap();
reg_instr.args.push(v);
let info = instr.op.info();
let n_pop = info.n_pop as usize + extra_pop;
let n_push = info.n_push as usize;
for _ in 0..n_pop {
let v = stack.pop().unwrap();
reg_instr.args.push(v);
}
for _ in 0..n_push {
let v = fresh_val();
stack.push(v);
reg_instr.dsts.push(v);
}
output.push(reg_instr);
}
for _ in 0..n_push {
let v = fresh_val();
stack.push(v);
reg_instr.dsts.push(v);
Self {
name: flat_fn.name,
arg_count: flat_fn.arg_count,
var_count: flat_fn.var_count,
value_names: flat_fn.value_names,
captures: flat_fn.captures,
cpool: flat_fn.cpool,
code: output,
next_val,
}
output.push(reg_instr);
}
output
pub fn fresh_val(&mut self) -> usize {
let v = self.next_val;
self.next_val += 1;
v
}
}
/// Lower every function's stack code to register code, keeping the rest.
pub fn regify(flat_fns: Vec<FlatFn>) -> Vec<RegFn> {
flat_fns
.into_iter()
.map(|f| RegFn {
code: regify_walk(&f.code, f.var_count),
name: f.name,
arg_count: f.arg_count,
var_count: f.var_count,
captures: f.captures,
cpool: f.cpool,
is_ctor: false,
})
.collect()
}
// ─── shared by the de* passes ──────────────────────────────────────────────
impl RegInstr {
pub fn new(op: OpCode, imm: ImmOperand, dsts: Vec<usize>, args: Vec<usize>) -> Self {
RegInstr {
dsts,
op,
imm,
args,
}
}
/// Whether this instruction reads or writes `value`.
pub fn mentions(&self, value: usize) -> bool {
self.args.contains(&value) || self.dsts.contains(&value)
}
}
/// Hands out value numbers that nothing in the function uses yet.
pub struct Fresh(usize);
impl Fresh {
/// Start just past the highest value the function already uses.
pub fn for_fn(f: &RegFn) -> Fresh {
let highest = f
.code
.iter()
.flat_map(|i| i.dsts.iter().chain(i.args.iter()))
.copied()
.max()
.unwrap_or(0);
Fresh(highest + 1)
}
pub fn new_value(&mut self) -> usize {
let v = self.0;
self.0 += 1;
v
}
}
/// A call with no receiver. `call_method` and `call_constructor` carry one and
/// are handled by the class passes instead.
pub fn is_plain_call(op: OpCode) -> bool {
use OpCode::*;
matches!(op, Call | Call0 | Call1 | Call2 | Call3 | TailCall)
}
/// The generic form of a call opcode: any tail variant becomes `tail_call`,
/// everything else `call`.
pub fn as_plain_call(op: OpCode) -> OpCode {
use OpCode::*;
if matches!(op, TailCall | TailCallMethod) {
TailCall
} else {
Call
}
}
/// An integer-named field. The synthetic objects these passes build use
/// integer names so no new atoms have to be minted.
pub fn int_field(index: u32) -> ImmOperand {
ImmOperand::Atom(AtomRef::TaggedInt(index))
flat_fns.into_iter().map(RegFn::from_flat).collect()
}
+193
View File
@@ -0,0 +1,193 @@
declass: class form -> prototype form
Plain text. Read it next to class_form.txt (the input) and proto_form.txt (the target).
Every instruction below is copied in the same dump format those files use.
The class in function "test" has this cpool:
cpool: [ FnById(2), FnById(3), FnById(4) ]
Const(0) = FnById(2) = the m1 method
Const(1) = FnById(3) = the constructor
Const(2) = FnById(4) = the fields initializer
====================================================================
STEP 1 in function "test": rewrite how the class is built
====================================================================
DETECT, abstractly:
push_const8 #ctor ; define_class @Name
( fclosure8 #method ; define_method @meth )*
( fclosure8 #fields ; set_home_object ; put_loc ; drop )?
which in the example is:
[5] = PushConst8 Const(1) [],
[6, 7] = DefineClass AtomU8 { atom: file_atom[2], arg: 0 } [5, 4],
[8] = Fclosure8 Const(0) [],
[9] = DefineMethod AtomU8 { atom: file_atom[5], arg: 0 } [8, 7],
[10] = Fclosure8 Const(2) [],
[11, 12] = SetHomeObject None [10, 9],
[] = PutLoc1 None [12],
[] = Drop None [11],
REPLACE it with:
[6] = Fclosure8 Const(1) [],
[8] = Fclosure8 Const(0) [],
[18] = GetField Atom(atom("prototype")) [6],
[] = PutField Atom(file_atom[5]) [8, 18],
INSTRUCTION BY INSTRUCTION:
PushConst8 Const(1) + DefineClass [5,4] -> Fclosure8 Const(1), keep dst [6]
Fclosure8 Const(0) -> unchanged (the method closure)
DefineMethod atom=A [8,7] -> GetField "prototype" [6]
then PutField atom=A [8,18]
Fclosure8 Const(2) -> deleted
SetHomeObject -> deleted
PutLoc1 [12] -> deleted
Drop [11] -> deleted
[18] is any fresh value id.
Repeat the DefineMethod line for each method in the class.
REMEMBER for step 2 and 3:
constructor function = Const(1) = FnById(3)
fields function = Const(2) = FnById(4)
class name = file_atom[2]
====================================================================
STEP 2 splice the field initialiser into the constructor FnById(3)
====================================================================
The constructor does not set its own fields. quickjs hoisted them into a
separate initialiser function (FnById(4)) that the constructor reaches through
the captured <class_fields_init> and runs with a call. Splice that function's
field writes straight into the constructor, in place of the call, so the
constructor initialises its fields inline and the separate initialiser is left
unused.
DETECT the call block, abstractly (from the get_var_ref_check of
<class_fields_init> through the next call_method):
get_var_ref_check <class_fields_init> ; dup ; if_false
; get_loc ; swap ; call_method
which in the example is:
[2] = GetVarRefCheck VarRef(0) [],
[3, 4] = Dup None [2],
[] = IfFalse8 Label { target: 14 } [4],
[5] = GetLoc0 None [],
[6, 7] = Swap None [5, 3],
[8] = CallMethod Npop(0) [7, 6],
REPLACE the call block by splicing in the field writes from the initialiser
FnById(4):
[9] = GetLoc0 None [],
[10] = Push1 None [],
[11] = DefineField Atom(file_atom[4]) [10, 9],
The initialiser FnById(4) in full looks like this:
[2] = SpecialObject U8(4) [],
[] = PutLoc1 None [2],
[3] = PushThis None [],
[] = PutLoc0 None [3],
[] = Goto8 Label { target: 7 } [],
[4] = GetLoc0 None [],
[5] = Push1 None [],
[6] = DefineField Atom(file_atom[4]) [5, 4],
[] = ReturnUndef None [],
HOW THE SPLICE IS BUILT:
Take the initialiser FnById(4). Drop these opcodes:
SpecialObject, PushThis, PutLoc0, PutLoc1, PutLoc2, PutLoc3,
Goto8, Goto, ReturnUndef
What is left is the field writes; these are what gets spliced in.
Renumber their value ids to fresh ids in the constructor: 4,5,6 -> 9,10,11.
The constructor's own PushThis / PutLoc0 / CheckCtor prologue and its
final ReturnUndef stay as they were, wrapping the spliced-in writes.
====================================================================
STEP 3 in the constructor FnById(3): fix its header
====================================================================
DETECT, abstractly:
a constructor (name "<null>") that captures <class_fields_init>
which in the example is:
name: "<null>",
captures: [ Capture { name: "<class_fields_init>", ... } ],
REPLACE with:
name: "Counter",
captures: [],
name becomes the class name from step 1.
Remove the <class_fields_init> capture.
If other captures remain after it, subtract 1 from every VarRef(n)
whose n is greater than the removed capture index.
====================================================================
ELIMINATED (opcodes this pass removes from live code)
====================================================================
Step 1 removes these from the class build:
DefineClass replaced by Fclosure8
DefineMethod replaced by GetField + PutField
SetHomeObject deleted
PushConst8 the class-template one, folded into Fclosure8
Fclosure8 the fields-init one, deleted
PutLoc1 the SetHomeObject leftover store, deleted
Drop the SetHomeObject leftover drop, deleted
Step 2 removes these from the constructor (the field-init call block):
GetVarRefCheck of the <class_fields_init> capture
Dup
IfFalse8
GetLoc0 the one feeding the call
Swap
CallMethod
After the pass, these three class-only opcodes never appear again anywhere:
DefineClass
DefineMethod
SetHomeObject
The rest (PushConst8, Fclosure8, GetVarRefCheck, Dup, IfFalse8, GetLoc0,
Swap, CallMethod, PutLoc1, Drop) still occur elsewhere for ordinary reasons.
Only the specific class-pattern instances listed above are removed.
====================================================================
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
====================================================================
LEFT BEHIND (removed by a later pass)
====================================================================
FnById(4), the fields function. Nothing references it anymore.
+187
View File
@@ -0,0 +1,187 @@
declosure: closures become plain objects
Plain text, same dump format as class_form.txt / proto_form.txt.
A closure becomes an object. Field 0 holds the function. Fields 1.. hold the
captured variables, in capture order. The object is also the environment, so
there is no separate env value.
Field names are plain integers, so a field operand reads Atom(int(k)):
field 0 the function
field k+1 capture number k
Running example:
function outer(a) {
function inner() { return a; } // inner captures a
return inner();
}
FnById(1) = outer arg_count 1 (a is arg 0)
FnById(2) = inner outer's cpool Const(0)
captures: [ Capture { name: "a", from: ParentArg(0) } ]
Three steps, one property each.
====================================================================
STEP 1 every function takes the env as its LAST parameter
====================================================================
Runs on every function. Capture reads/writes become field access on the env.
arg_count grows by one; existing args keep their numbers. The env is loaded
once at entry, but only if the function touches a capture. Then the capture
list is cleared.
DETECT, abstractly (any function; each capture access, one at a time):
capture read get_var_ref | get_var_ref_check | get_var_ref0..3
capture write put_var_ref | put_var_ref_check | put_var_ref_check_init
| put_var_ref0..3
inner, which in the example is:
captures: [ Capture { name: "a", from: ParentArg(0) } ],
arg_count: 0,
[0] = GetVarRefCheck VarRef(0) [],
[] = Return None [0],
REPLACE with:
captures: [],
arg_count: 1,
[1] = GetArg Arg(0) [], // env, the new last parameter
[0] = GetField Atom(int(1)) [1], // capture 0 -> field 1
[] = Return None [0],
INSTRUCTION BY INSTRUCTION:
load env once at entry -> GetArg Arg(<new last arg>) [env]
GetVarRef / GetVarRefCheck k -> GetField Atom(int(k+1)) [env]
GetVarRef0..3 -> GetField Atom(int(1..4)) [env]
PutVarRef / PutVarRefCheck k -> PutField Atom(int(k+1)) [value, env]
PutVarRef0..3 -> PutField Atom(int(1..4)) [value, env]
A function that touches no capture still gains the env parameter, but no
GetArg is emitted for it. The uniform parameter is what lets step 3 append
the env at every call site.
====================================================================
STEP 2 every fclosure builds the object, in the PARENT
====================================================================
Each fclosure becomes: load the function as a plain constant, make the object,
store the function in field 0, then one field per capture loaded from the
parent frame. The DefineField results chain; the LAST store must land in the
value id the rest of the code already uses for the closure.
DETECT, abstractly:
fclosure #child (#child resolves to a FnById in cpool)
outer, which in the example is:
[3] = Fclosure8 Const(0) [], // Const(0) = FnById(2) = inner
REPLACE with:
[10] = PushConst8 Const(0) [], // the function, as a plain constant
[11] = Object None [], // the closure object
[12] = DefineField Atom(int(0)) [10, 11], // field 0 = function
[13] = GetArg Arg(0) [], // capture 0 source: outer's arg 0 (a)
[3] = DefineField Atom(int(1)) [13, 12], // field 1 = capture a; last store -> [3]
A closure with NO captures is just field 0, and that store lands in [3]:
[3] = Fclosure8 Const(0) [],
->
[10] = PushConst8 Const(0) [],
[11] = Object None [],
[3] = DefineField Atom(int(0)) [10, 11],
CAPTURE SOURCE -> how it loads (from the parent frame):
ParentLocal(s) -> GetLoc Loc(s) [value]
ParentArg(s) -> GetArg Arg(s) [value]
anything else -> Undefined None [value] // gap, see below
DefineField args are [value, obj], and it returns the object, so the obj
of each store is the result of the store before it.
====================================================================
STEP 3 every plain call goes through the object, in the CALLER
====================================================================
A call names a closure object now. Read the function out of field 0, call it,
and pass the object itself as the trailing env argument. The callee is the
last argument, so it is args.last().
DETECT, abstractly:
plain call call | call0..3 (callee = last arg; not call_method,
call_constructor, or apply)
outer, which in the example is:
[5] = Call Npop(0) [4], // [4] is the closure object
REPLACE with:
[14] = GetField Atom(int(0)) [4], // the function, out of field 0
[5] = Call Npop(1) [4, 14], // call it; env (the object) appended
Npop grows by one, for the appended env. Args become:
[ env, <original call args>, func ]
env = the object value (the old callee)
func = field 0 of the object (the new callee, still last)
====================================================================
ELIMINATED (opcodes this pass removes from live code)
====================================================================
Step 1 removes:
GetVarRef, GetVarRefCheck, GetVarRef0..3 replaced by GetField on env
PutVarRef, PutVarRefCheck,
PutVarRefCheckInit, PutVarRef0..3 replaced by PutField on env
Step 2 removes:
Fclosure, Fclosure8 replaced by PushConst8 + Object
+ DefineField(s)
After the pass, these never appear again anywhere:
Fclosure, Fclosure8
every GetVarRef* and PutVarRef*
====================================================================
NOT TOUCHED (passed through unchanged)
====================================================================
CallMethod carries a receiver; the class passes own it
CallConstructor carries a receiver; the class passes own it
Apply carries a receiver
GetVarRef* / PutVarRef* that name a source this pass does not model
(see gaps) still become field access, but load
Undefined
====================================================================
GAPS (known, left for later)
====================================================================
A captured var the parent later writes: the write makes a new value, so
the closure never sees it. A capture aliases the value at capture time.
Capture source is ParentCapture / module binding / global: step 2 has no
parent frame slot to read, so it loads Undefined.
Foreign callee (e.g. Math.sqrt): step 3 reads field 0, but a host builtin
has no field 0.
Entry points (module root, host-invoked functions): step 1 gives them an
env parameter, but no caller of ours supplies one.
+210
View File
@@ -0,0 +1,210 @@
demutate: object create + in-place mutation become function calls
Plain text, same dump format as class_form.txt / proto_form.txt.
Goal: every point that builds or mutates an object becomes a call to a function
that RETURNS a fresh object. A mutation function copies the input's fields with
a spread and re-applies only the writes it was given; it never names the rest.
====================================================================
THE KEY IDEA
====================================================================
Find a maximal contiguous RUN of static-key field writes to one object -- the
Object that creates it, the DefineField / PutField writes with an atom key, and
the value computations they consume -- and lift the whole run into one function.
creation run Object ; ( <value> ; DefineField @F )+
mutation run ( <object> ; <value> ; PutField @F )+ (same object)
The function returns the finished object. The call site replaces the run with:
load the parameters, make the closure, call it, and put the result where the
run's object went (the escaping value for a creation, a store-back for a
mutation).
Creation and mutation are ONE code path. The only difference is whether the
run's object is made inside it (creation -> start from an empty Object) or
loaded from outside (mutation -> the object is a parameter, spread in first).
Creation is just mutation of a freshly-made empty object; PutField and
DefineField both become DefineField on the fresh copy the function owns.
====================================================================
PARAMETERS -- the rule that keeps this general
====================================================================
A value the run USES but does not itself PRODUCE becomes a parameter, in
first-use order. Everything produced inside the run (Object, constants,
arithmetic that is wholly inside) moves into the function unchanged.
the object being mutated is used-but-not-produced -> it is a parameter.
a constant field value (Push1) is produced inside -> it moves in.
a field value from the enclosing frame (GetArg p) -> a parameter.
So a pure literal of constants lifts to a 0-parameter function; a mutation of
`a` lifts to a 1-parameter function; `a.x = p + 1` lifts to a 2-parameter one.
====================================================================
FUNCTION BODY -- how the run is rebuilt
====================================================================
The lifted function always builds ONE fresh object and returns it.
if the run has an incoming object (a parameter), start as a spread copy:
[0] = Object None [],
[1] = GetArg0 None [], // the incoming object
[2] = Null None [],
[3, 4, 5] = CopyDataProperties U8(6) [2, 1, 0], // copy ALL its fields
[] = Drop None [5],
[] = Drop None [4],
otherwise (a creation), start empty:
[0] = Object None [],
then, for each field write in the run, in order:
<value> // moved in, or GetArgK for a parameter
DefineField Atom(@F) [value, obj]
and finally:
[] = Return None [obj],
Each generated function is appended to the flat function list, gets the next fn
id N, and FnById(N) is added to the caller's cpool at some slot K; the call site
loads it with Fclosure8 Const(K).
====================================================================
DETECTION (one linear walk)
====================================================================
start a run at an Object, or at the first static-key write to an object.
extend it while the next instruction is one of:
Object (a nested object is its own run)
a value computation feeding a write in this run
DefineField, imm == Atom(F) (same object)
PutField, imm == Atom(F) (same object)
a (re)load of the same object
END the run at any of:
the object is READ for anything but a field write -- passed to a call,
used in an expression, branched on.
a static-key write to a DIFFERENT object.
"Same object": a mutation re-loads the object before each write (each
GetArg0 / GetLocCheck is a fresh value), so sameness is judged by the load
SOURCE -- the same Arg or Loc -- not the value id. quickjs keeps writes in
source order and does not group by object, so interleaved writes to two
objects (a.x; b.y; a.z) split into three runs, not two.
Ignore computed-key writes (DefineArrayEl / PutArrayEl) -- see extensions.
====================================================================
WORKED EXAMPLE (test), real instructions
====================================================================
--- the creation run ---
DETECT:
[1] = Object None [],
[2] = Push1 None [],
[3] = DefineField Atom(file_atom[7]) [2, 1],
[4] = Push0 None [],
[5] = DefineField Atom(file_atom[8]) [4, 3],
[] = PutLoc0 None [5],
nothing is used from outside, so the function takes no parameters.
REPLACE with (the run's result stays value [5], so PutLoc0 is untouched):
[20] = Fclosure8 Const(Kc) [],
[5] = Call0 None [20],
[] = PutLoc0 None [5],
new FnById(Nc), name "<create>", arg_count 0:
[0] = Object None [],
[1] = Push1 None [],
[2] = DefineField Atom(file_atom[7]) [1, 0],
[3] = Push0 None [],
[4] = DefineField Atom(file_atom[8]) [3, 2],
[] = Return None [4],
--- the mutation run (a.field1 = 2 ; a.other = 3) ---
DETECT:
[6] = GetLocCheck Loc(0) [],
[7] = Push2 None [],
[] = PutField Atom(file_atom[7]) [7, 6],
[8] = GetLocCheck Loc(0) [],
[9] = Push3 None [],
[] = PutField Atom(file_atom[9]) [9, 8],
the object (Loc 0) is used-but-not-produced -> the one parameter; 2 and 3 are
produced inside -> they move in.
REPLACE with (store the returned object back):
[21] = Fclosure8 Const(Km) [],
[22] = GetLocCheck Loc(0) [], // the incoming object
[23] = Call1 None [22, 21], // a = mutate(a); callee is last
[] = PutLoc0 None [23],
new FnById(Nm), name "<mutate>", arg_count 1:
[0] = Object None [],
[1] = GetArg0 None [],
[2] = Null None [],
[3, 4, 5] = CopyDataProperties U8(6) [2, 1, 0],
[] = Drop None [5],
[] = Drop None [4],
[6] = Push2 None [],
[7] = DefineField Atom(file_atom[7]) [6, 3],
[8] = Push3 None [],
[9] = DefineField Atom(file_atom[9]) [8, 7],
[] = Return None [9],
====================================================================
FIELD VALUE FORMS -- all handled the same way
====================================================================
constant a.x = 1 Push1 moves into the function
argument { x: p } GetArg becomes a parameter
computed a.x = p + 1 p is a parameter; the Add moves in
field read a.x = b.y b is a parameter; the GetField moves in
nested object { x: { y: 1 } } the inner Object is its own run, lifted
first; its result is a value the outer
run uses -> a parameter of the outer
====================================================================
NEEDS A SEPARATE HELPER (extensions)
====================================================================
spread in a literal { ...b, x: 1 } CopyDataProperties of a source
object, not just the input copy
computed key { [k]: 1 }, a[k] = 1 DefineArrayEl / PutArrayEl,
a[0] = 1 keyed by a value, not an atom
====================================================================
GAPS (left for later)
====================================================================
A mutation run's object must live in a local, so the returned object can be
written back and the next read sees it. A bare param or a sub-expression
has nowhere to write back.
Aliasing / identity: the run returns a NEW object, so two names for the same
object diverge, and === changes.
Ordering: a run must stay in program order; it is cut wherever the object is
read for anything but a field write.
Methods ({ m() {} } -> DefineMethod), getters/setters, delete,
defineProperty: not plain field writes.