Compare commits

..
2 Commits
Author SHA1 Message Date
uditdey 92451e9cdc Fable rewrite 2026-09-02 19:35:21 +00:00
uditdey 93e6020f7a Temp commit 2026-09-02 19:25:26 +00:00
6 changed files with 1060 additions and 153 deletions
+336
View File
@@ -0,0 +1,336 @@
//! 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,
}
}
+252
View File
@@ -0,0 +1,252 @@
//! 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
@@ -0,0 +1,274 @@
//! 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
}
+27 -3
View File
@@ -1,12 +1,18 @@
mod declass;
mod declosure;
mod deprototype;
mod flatten;
mod parse;
mod ssa;
mod regify;
use std::process::Command;
use declass::declass;
use declosure::declosure;
use deprototype::deprototype;
use flatten::flatten;
use parse::QuickJSBinary;
use ssa::convert_ssa;
use regify::regify;
const QJSC: &str = "../third_party/quickjs/build/qjsc";
const BIN_OUT: &str = "/tmp/qjsrt.bin";
@@ -30,5 +36,23 @@ fn main() {
let (flat_fns, atoms) = flatten(parsed);
println!("{atoms:#?}");
convert_ssa(flat_fns);
let reg_fns = regify(flat_fns);
println!("=== after regify ===");
println!("{reg_fns:#?}");
let declassed = declass(reg_fns);
println!("=== after declass ===");
println!("{declassed:#?}");
let deprototyped = deprototype(declassed);
println!("=== after deprototype ===");
println!("{deprototyped:#?}");
let declosured = declosure(deprototyped);
println!("=== after declosure ===");
println!("{declosured:#?}");
}
+171
View File
@@ -0,0 +1,171 @@
#![allow(non_snake_case)]
use std::fmt::Debug;
use crate::{
flatten::{Capture, FlatFn},
parse::{AtomRef, ImmOperand, Instr, JsValue, opcodes::OpCode},
};
pub struct RegInstr {
pub dsts: Vec<usize>,
pub op: OpCode,
pub imm: ImmOperand,
pub args: Vec<usize>,
}
impl Debug for RegInstr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{:?} = {:?} {:?} {:?}",
self.dsts, self.op, self.imm, self.args
)
}
}
/// 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 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,
}
fn regify_walk(code: &[Instr], var_count: usize) -> Vec<RegInstr> {
let mut stack = vec![];
let mut output = vec![];
let mut next_val = 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 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,
},
};
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);
}
output
}
/// 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))
}
-150
View File
@@ -1,150 +0,0 @@
#![allow(non_snake_case)]
use crate::{
flatten::FlatFn,
parse::{ImmOperand, opcodes::OpCode},
};
fn is_jump_op(op: OpCode) -> bool {
use OpCode::*;
matches!(
op,
Goto | Goto8 | Goto16 | IfFalse | IfTrue | IfFalse8 | IfTrue8
)
}
fn is_terminal_op(op: OpCode) -> bool {
use OpCode::*;
matches!(op, Return | ReturnUndef | ReturnAsync | Throw)
}
#[derive(Debug)]
enum Terminator {
UncondJump(usize),
CondJump {
truthy: bool,
value: usize,
true_block: usize,
false_block: usize,
},
Terminal,
}
#[derive(Debug)]
struct BlockSpan {
start: usize,
end: usize,
term: Terminator,
}
fn find_basic_blocks(flat_fn: &FlatFn) -> Vec<BlockSpan> {
// Find block starts: the first instruction, every jump target,
// and whatever follows a jump or a return
let code = &flat_fn.code;
let mut starts = vec![0];
for (i, instr) in code.iter().enumerate() {
// If cond/uncond jump, jump target is a start, next instr (if exists) is another start
if is_jump_op(instr.op) {
let jump_target = match instr.imm_operands {
ImmOperand::Label { target } => code.iter().position(|x| x.pc == target).unwrap(),
_ => unreachable!(),
};
starts.push(jump_target);
if i + 1 < code.len() {
starts.push(i + 1);
}
}
// If terminal, next instr (if exists) is a start
if is_terminal_op(instr.op) {
if i + 1 < code.len() {
starts.push(i + 1);
}
}
}
starts.sort_unstable();
starts.dedup();
// Create blocks, one per block start
let blocks = starts
.iter()
.enumerate()
.map(|(i, &start)| BlockSpan {
start,
end: starts.get(i + 1).copied().unwrap_or(code.len()),
term: Terminator::Terminal,
})
.collect::<Vec<BlockSpan>>();
blocks
}
#[derive(Debug)]
struct SsaInstr {
dsts: Vec<usize>,
op: OpCode,
imm: ImmOperand,
args: Vec<usize>,
}
fn ssa_walk(flat_fn: &FlatFn) -> Vec<SsaInstr> {
//let mut locals = vec![];
let mut stack = vec![];
let mut output = vec![];
let mut next_val = 0;
let mut fresh_val = || {
let v = next_val;
next_val += 1;
v
};
for instr in &flat_fn.code {
let mut ssa_instr = SsaInstr {
dsts: vec![],
op: instr.op,
imm: instr.imm_operands,
args: vec![],
};
let info = instr.op.info();
let n_pop = info.n_pop;
let n_push = info.n_push;
for _ in 0..n_pop {
let v = stack.pop().unwrap();
ssa_instr.args.push(v);
}
for _ in 0..n_push {
let v = fresh_val();
stack.push(v);
ssa_instr.dsts.push(v);
}
output.push(ssa_instr);
}
output
}
fn compute_terminators(ssa: &[SsaInstr], blocks: &mut [BlockSpan]) {
for instr in ssa {
if is_jump_op(instr.op) {}
}
}
pub fn convert_ssa(flat_fns: Vec<FlatFn>) {
for flat_fn in flat_fns {
let blocks = find_basic_blocks(&flat_fn);
let ssa = ssa_walk(&flat_fn);
println!("{flat_fn:#?}");
println!("{blocks:#?}");
println!("{ssa:#?}");
}
}