From 93e6020f7a139e96d04af5313d04d64b21f2524e Mon Sep 17 00:00:00 2001 From: uditdey Date: Wed, 2 Sep 2026 19:25:26 +0000 Subject: [PATCH] Temp commit --- compiler/src/declass.rs | 351 ++++++++++++++++++++++++++++++++++++ compiler/src/declosure.rs | 293 ++++++++++++++++++++++++++++++ compiler/src/deprototype.rs | 291 ++++++++++++++++++++++++++++++ compiler/src/main.rs | 30 ++- compiler/src/regify.rs | 106 +++++++++++ compiler/src/ssa.rs | 150 --------------- 6 files changed, 1068 insertions(+), 153 deletions(-) create mode 100644 compiler/src/declass.rs create mode 100644 compiler/src/declosure.rs create mode 100644 compiler/src/deprototype.rs create mode 100644 compiler/src/regify.rs delete mode 100644 compiler/src/ssa.rs diff --git a/compiler/src/declass.rs b/compiler/src/declass.rs new file mode 100644 index 0000000..a1c070b --- /dev/null +++ b/compiler/src/declass.rs @@ -0,0 +1,351 @@ +//! 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 — no +//! separate instantiation. +//! +//! A derived class starts its prototype as a COPY of the parent's, then +//! installs its own methods over it, flattening the hierarchy where it is +//! declared so no chain survives to be walked: +//! +//! raw = object +//! pp = get_field parent "prototype" +//! proto = copy_data_properties [raw, pp] +//! +//! 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::{RegFn, RegInstr}, +}; + +/// Synthetic field name recording a derived class's parent constructor, read +/// by `deprototype`'s `super` lowering. Integer-named so no atom is minted; the +/// high value keeps it clear of the positional fields other passes use. +pub const PARENT_LINK: u32 = 0xFFFF_FFFF; + +/// The `__parent` field name as an operand. +pub fn parent_link() -> ImmOperand { + ImmOperand::Atom(AtomRef::TaggedInt(PARENT_LINK)) +} + +/// Lower every class in the program to prototype form. +pub fn declass(mut fns: Vec) -> Vec { + // Parameter counts, read before any pass changes them. + let arity: Vec = fns.iter().map(|f| f.arg_count).collect(); + + // Derived constructors found while lowering, with the parent's arity. + let mut derived: Vec<(usize, usize)> = Vec::new(); + // Every function that is a class constructor. + let mut ctors: Vec = Vec::new(); + for f in &mut fns { + let mut next = max_val(f) + 1; + lower(f, &arity, &mut derived, &mut ctors, &mut next); + } + + 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 { + if let Some(f) = fns.get_mut(id) { + write_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 Pending { + ctor: usize, + cval: usize, + proto_cur: usize, + derived: bool, + parent: usize, +} + +fn lower( + f: &mut RegFn, + arity: &[usize], + derived_out: &mut Vec<(usize, usize)>, + ctors_out: &mut Vec, + next: &mut usize, +) { + // Which values are function-template constants, as `(value, fn id)`. + let templates = template_ids(f); + + let mut ctor_of: Vec<(usize, usize)> = Vec::new(); // ctor value -> fn id + let mut pending: Vec = Vec::new(); + let code = std::mem::take(&mut f.code); + let mut out = Vec::with_capacity(code.len()); + + for ri in code { + match ri.op { + OpCode::DefineClass if ri.dsts.len() == 2 && ri.args.len() == 2 => { + let ImmOperand::AtomU8 { arg: flags, .. } = ri.imm else { + out.push(ri); + continue; + }; + let ctor = ri.dsts[0]; + let proto = ri.dsts[1]; + let template = ri.args[0]; // popped first: top of stack + let parent = ri.args[1]; + let derived = flags & 1 != 0; + + let ctor_fn = templates.iter().find(|(v, _)| *v == template).map(|(_, i)| *i); + let cval = template; // function-as-const constructor + + if derived { + // Flatten the hierarchy: start from a copy of the parent's + // prototype, then install this class's methods over it. + let raw = fresh(next); + let pp = fresh(next); + out.push(instr(OpCode::Object, ImmOperand::None, vec![raw], vec![])); + out.push(instr(OpCode::GetField, proto_atom(), vec![pp], vec![parent])); + out.push(instr( + OpCode::CopyDataProperties, + ImmOperand::U8(0), + vec![proto], + vec![raw, pp], + )); + + if let (Some(cid), Some(pid)) = ( + ctor_fn, + ctor_of.iter().find(|(v, _)| *v == parent).map(|(_, i)| *i), + ) { + derived_out.push((cid, arity.get(pid).copied().unwrap_or(0))); + } + } else { + out.push(instr(OpCode::Object, ImmOperand::None, vec![proto], vec![])); + } + + if let Some(id) = ctor_fn { + ctor_of.push((ctor, id)); + ctors_out.push(id); + } + pending.push(Pending { + ctor, + cval, + proto_cur: 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(&ri) == Some(0) && ri.args.len() == 2 => { + let ImmOperand::AtomU8 { atom, .. } = ri.imm else { + out.push(ri); + continue; + }; + let dst = ri.dsts[0]; + let obj = ri.args[1]; + let method = ri.args[0]; + out.push(instr(OpCode::DefineField, atom_imm(atom), vec![dst], vec![obj, method])); + if let Some(p) = pending.iter_mut().find(|p| p.proto_cur == obj) { + p.proto_cur = dst; + } + } + + _ => { + // Reading a constructor forces its prototype to be finished. + let mut i = 0; + while i < pending.len() { + let c = pending[i].ctor; + if ri.args.contains(&c) || ri.dsts.contains(&c) { + flush(pending.remove(i), &mut out, next); + } else { + i += 1; + } + } + out.push(ri); + } + } + } + + while let Some(p) = pending.pop() { + flush(p, &mut out, next); + } + f.code = out; +} + +/// Hang the finished prototype off the constructor — this is what `.prototype` +/// is. `define_field` returns the object, so the constructor lands in `ctor`. A +/// derived class also records its parent constructor in `__parent`, which is +/// what `deprototype`'s `super` reads. +fn flush(p: Pending, out: &mut Vec, next: &mut usize) { + let hung = if p.derived { fresh(next) } else { p.ctor }; + out.push(instr( + OpCode::DefineField, + proto_atom(), + vec![hung], + vec![p.cval, p.proto_cur], + )); + if p.derived { + out.push(instr( + OpCode::DefineField, + parent_link(), + vec![p.ctor], + vec![hung, p.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 write_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 next = max_val(f) + 1; + + let code = std::mem::take(&mut f.code); + let mut out = Vec::with_capacity(code.len() + 3 + parent_arity); + for ri in code { + if ri.op != OpCode::InitCtor { + out.push(ri); + continue; + } + let this = ri.dsts[0]; + + // `this = super(a0 … an)`, spelled the way a written constructor is. + let this_fn = fresh(&mut next); + let parent = fresh(&mut next); + let new_target = fresh(&mut next); + out.push(instr(OpCode::SpecialObject, ImmOperand::U8(2), vec![this_fn], vec![])); // running fn + out.push(instr(OpCode::GetSuper, ImmOperand::None, vec![parent], vec![this_fn])); + out.push(instr(OpCode::Undefined, ImmOperand::None, vec![new_target], vec![])); + + let mut argvals = Vec::with_capacity(parent_arity); + for i in 0..parent_arity { + let a = fresh(&mut next); + out.push(instr(OpCode::GetArg, ImmOperand::Arg(i as u16), vec![a], vec![])); + argvals.push(a); + } + + // call_constructor pops [func, new_target, args..], args on top; so the + // register args run: arguments (reversed), new_target, then func. + let mut args = Vec::with_capacity(parent_arity + 2); + args.extend(argvals.iter().rev().copied()); + args.push(new_target); + args.push(parent); + out.push(instr( + OpCode::CallConstructor, + ImmOperand::Npop(parent_arity as u16), + vec![this], + args, + )); + } + f.code = out; +} + +// ─── helpers ─────────────────────────────────────────────────────────────── + +/// Values bound to a nested-function constant, as `(value, fn id)`. A function +/// is loaded with `push_const` (or `fclosure`, before it is lowered). +fn template_ids(f: &RegFn) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + for ri in &f.code { + let is_const = matches!( + ri.op, + OpCode::PushConst | OpCode::PushConst8 | OpCode::Fclosure | OpCode::Fclosure8 + ); + if let (true, ImmOperand::Const(c), Some(&dst)) = (is_const, ri.imm, ri.dsts.first()) { + if let Some(JsValue::FnById(id)) = f.cpool.get(c as usize) { + out.push((dst, *id)); + } + } + } + out +} + +/// The method kind carried by a `define_method` (0 = plain method). +fn method_kind(ri: &RegInstr) -> Option { + match ri.imm { + ImmOperand::AtomU8 { arg, .. } => Some(arg), + _ => None, + } +} + +fn instr(op: OpCode, imm: ImmOperand, dsts: Vec, args: Vec) -> RegInstr { + RegInstr { + dsts, + op, + imm, + args, + } +} + +fn atom_imm(a: AtomRef) -> ImmOperand { + ImmOperand::Atom(a) +} + +/// The `"prototype"` field name, resolved to its predefined atom once. +pub fn proto_atom() -> ImmOperand { + let idx = crate::parse::atom_names::PREDEFINED_ATOMS + .iter() + .position(|&a| a == "prototype") + .expect("prototype is a predefined atom"); + ImmOperand::Atom(AtomRef::Predefined(idx as u32)) +} + +fn fresh(next: &mut usize) -> usize { + let v = *next; + *next += 1; + v +} + +fn max_val(f: &RegFn) -> usize { + f.code + .iter() + .flat_map(|i| i.dsts.iter().chain(i.args.iter())) + .copied() + .max() + .unwrap_or(0) +} diff --git a/compiler/src/declosure.rs b/compiler/src/declosure.rs new file mode 100644 index 0000000..fbe560b --- /dev/null +++ b/compiler/src/declosure.rs @@ -0,0 +1,293 @@ +//! Transform: closures become plain objects. +//! +//! A closure becomes an object holding its captured variables plus its +//! function. Field 0 is the function; fields 1.. are the captures, in +//! capture-index order. Integer field names (`AtomRef::TaggedInt`) are used +//! so no new atoms have to be minted. The object doubles as the environment, +//! so there is no second env value to track. +//! +//! Three passes, one property each: +//! +//! 1. `pass_env_param` — every function takes the env as its LAST +//! parameter and reads its captures out of it, so +//! `get_var_ref` / `put_var_ref` become field +//! access on the env. +//! 2. `pass_closure_object` — every `fclosure` builds the object: the +//! function in field 0, then one field per +//! capture, each loaded from the parent frame. +//! 3. `pass_call_site` — 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 pass 3 +//! can append the object at a uniform call site. +//! +//! Notes on this lowering: +//! +//! * A function is treated as a plain constant — a pointer into the cpool. +//! `fclosure` is replaced by `push_const`, so it loads like any other +//! const, and storing it in a field is no different from storing a number. +//! * In register form the callee is the LAST call argument (it is the +//! deepest stack slot, popped last), so pass 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.** Pass 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.** Pass 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.** Pass 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 method and class transforms; pass 3 leaves them +//! untouched. Same for `apply`. + +use crate::{ + flatten::CaptureFrom, + parse::{AtomRef, ImmOperand, JsValue, opcodes::OpCode}, + regify::{RegFn, RegInstr}, +}; + +/// Capture sources per fn id, snapshotted before pass 1 clears the lists. +type Sources = Vec>; + +pub fn declosure(mut fns: Vec) -> Vec { + let sources: Sources = fns + .iter() + .map(|f| f.captures.iter().map(|c| c.from).collect()) + .collect(); + + for f in &mut fns { + let mut next = max_val(f) + 1; + pass_env_param(f, &mut next); + pass_closure_object(f, &sources, &mut next); + pass_call_site(f, &mut next); + } + fns +} + +// ─── 1. every function takes an env ──────────────────────────────────────── + +/// Append `env` as the last parameter — so existing arguments keep their +/// numbers — and turn every capture access into a field access on it. A read +/// keeps its destination value, so uses downstream do not change. +fn pass_env_param(f: &mut RegFn, next: &mut usize) { + let env_idx = f.arg_count; + f.arg_count += 1; + let env = fresh(next); + + let code = std::mem::take(&mut f.code); + let mut out = Vec::with_capacity(code.len() + 1); + let mut used = false; + + for ri in code { + match varref(&ri) { + Some((false, k)) => { + used = true; + out.push(instr(OpCode::GetField, field(k + 1), ri.dsts, vec![env])); + } + Some((true, k)) => { + used = true; + let mut args = vec![env]; + args.extend(ri.args); // the value being stored + out.push(instr(OpCode::PutField, field(k + 1), vec![], args)); + } + None => out.push(ri), + } + } + + // Load the env once, at entry — only if a capture actually reads it. + if used { + out.insert( + 0, + instr(OpCode::GetArg, ImmOperand::Arg(env_idx as u16), vec![env], vec![]), + ); + } + + f.code = out; + f.captures.clear(); +} + +// ─── 2. every fclosure builds the object ─────────────────────────────────── + +/// Replace each `fclosure` with: the function const (field 0), then one +/// `define_field` per capture, each loaded from the parent frame. The stores +/// chain, and the last lands in the closure's original destination. +fn pass_closure_object(f: &mut RegFn, sources: &Sources, next: &mut usize) { + let code = std::mem::take(&mut f.code); + let mut out = Vec::with_capacity(code.len()); + + for ri in code { + let child = fclosure_target(&ri, &f.cpool); + let (Some(child_id), Some(&dst)) = (child, ri.dsts.first()) else { + out.push(ri); + continue; + }; + + // The function is a plain constant — a pointer into the cpool — so it + // loads with `push_const`, no different from any other const. + let load_op = match ri.op { + OpCode::Fclosure8 => OpCode::PushConst8, + _ => OpCode::PushConst, + }; + let fp = fresh(next); + out.push(instr(load_op, ri.imm, vec![fp], vec![])); + + let obj = fresh(next); + out.push(instr(OpCode::Object, ImmOperand::None, vec![obj], vec![])); + + let caps: &[CaptureFrom] = sources.get(child_id).map(Vec::as_slice).unwrap_or(&[]); + + // Field 0 is the function. It is the last store only when there are no + // captures; otherwise the last capture lands in `dst`. + let mut cur = if caps.is_empty() { dst } else { fresh(next) }; + out.push(instr(OpCode::DefineField, field(0), vec![cur], vec![obj, fp])); + + for (k, from) in caps.iter().enumerate() { + let cap = fresh(next); + out.push(load_capture(*from, cap)); + let last = k + 1 == caps.len(); + let d = if last { dst } else { fresh(next) }; + out.push(instr(OpCode::DefineField, field(k + 1), vec![d], vec![cur, cap])); + cur = d; + } + } + + f.code = out; +} + +/// The instruction to materialize one capture in the parent frame. Only parent +/// locals and args are supported; other sources are a preserved gap and load +/// `undefined`. +fn load_capture(from: CaptureFrom, dst: usize) -> RegInstr { + match from { + CaptureFrom::ParentLocal(i) => { + instr(OpCode::GetLoc, ImmOperand::Loc(i as u16), vec![dst], vec![]) + } + CaptureFrom::ParentArg(i) => { + instr(OpCode::GetArg, ImmOperand::Arg(i as u16), vec![dst], vec![]) + } + _ => instr(OpCode::Undefined, ImmOperand::None, vec![dst], vec![]), + } +} + +// ─── 3. every call goes through the object ───────────────────────────────── + +/// A call now names a closure object, not a function, so it reads field 0 and +/// passes the object itself as the trailing env argument. +fn pass_call_site(f: &mut RegFn, next: &mut usize) { + let code = std::mem::take(&mut f.code); + let mut out = Vec::with_capacity(code.len()); + + for ri in code { + if !is_plain_call(ri.op) || ri.args.is_empty() { + out.push(ri); + continue; + } + + // In register form the callee is the deepest arg, popped last. + let callee = *ri.args.last().unwrap(); + let fnp = fresh(next); + out.push(instr(OpCode::GetField, field(0), vec![fnp], vec![callee])); + + let mut args = Vec::with_capacity(ri.args.len() + 1); + args.push(fnp); + args.extend_from_slice(&ri.args[..ri.args.len() - 1]); + args.push(callee); // the env, last + + let op = if is_tail_call(ri.op) { + OpCode::TailCall + } else { + OpCode::Call + }; + out.push(instr(op, ri.imm, ri.dsts, args)); + } + + f.code = out; +} + +// ─── classification ──────────────────────────────────────────────────────── + +/// `(is_write, capture_index)` if the instruction is a closure-var access. +fn varref(ri: &RegInstr) -> Option<(bool, usize)> { + use OpCode::*; + let imm_idx = || match ri.imm { + ImmOperand::VarRef(k) => k as usize, + _ => 0, + }; + Some(match ri.op { + GetVarRef | GetVarRefCheck => (false, imm_idx()), + GetVarRef0 => (false, 0), + GetVarRef1 => (false, 1), + GetVarRef2 => (false, 2), + GetVarRef3 => (false, 3), + PutVarRef | PutVarRefCheck | PutVarRefCheckInit => (true, imm_idx()), + PutVarRef0 => (true, 0), + PutVarRef1 => (true, 1), + PutVarRef2 => (true, 2), + PutVarRef3 => (true, 3), + _ => return None, + }) +} + +/// The child fn id if this instruction is an `fclosure` over a cpool function. +fn fclosure_target(ri: &RegInstr, cpool: &[JsValue]) -> Option { + if !matches!(ri.op, OpCode::Fclosure | OpCode::Fclosure8) { + return None; + } + let idx = match ri.imm { + ImmOperand::Const(c) => c as usize, + _ => return None, + }; + match cpool.get(idx) { + Some(JsValue::FnById(id)) => Some(*id), + _ => None, + } +} + +/// `call_method` and `call_constructor` carry a receiver and are left alone. +fn is_plain_call(op: OpCode) -> bool { + use OpCode::*; + matches!(op, Call | Call0 | Call1 | Call2 | Call3 | TailCall) +} + +fn is_tail_call(op: OpCode) -> bool { + matches!(op, OpCode::TailCall) +} + +// ─── helpers ─────────────────────────────────────────────────────────────── + +fn instr(op: OpCode, imm: ImmOperand, dsts: Vec, args: Vec) -> RegInstr { + RegInstr { + dsts, + op, + imm, + args, + } +} + +/// Integer field name: 0 for the function, k+1 for capture k. +fn field(k: usize) -> ImmOperand { + ImmOperand::Atom(AtomRef::TaggedInt(k as u32)) +} + +fn fresh(next: &mut usize) -> usize { + let v = *next; + *next += 1; + v +} + +fn max_val(f: &RegFn) -> usize { + f.code + .iter() + .flat_map(|i| i.dsts.iter().chain(i.args.iter())) + .copied() + .max() + .unwrap_or(0) +} diff --git a/compiler/src/deprototype.rs b/compiler/src/deprototype.rs new file mode 100644 index 0000000..142e15b --- /dev/null +++ b/compiler/src/deprototype.rs @@ -0,0 +1,291 @@ +//! 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. +//! +//! * **`this` becomes a parameter.** Every function takes `self` as its last +//! parameter, and `this` (the `push_this` opcode) reads it. Appended, like +//! the env, so existing arguments keep their numbers. Applied uniformly — a +//! function that never mentions `this` still takes one — because a call +//! site cannot know whether its callee does. +//! +//! * **A constructor builds and returns its object.** `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 (declass installed them +//! with a plain `define_field`, so `{...proto}` copies them), so a +//! `get_field` finds one exactly as it finds a field, and no chain is +//! walked at run time. +//! +//! * **A method call is a plain call.** `o.m(a)` passes `o` as the trailing +//! receiver 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 +//! site is left with its callee still 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::{RegFn, RegInstr}, +}; + +/// Sentinel for "this function has no `this` value". +const NO_SELF: usize = usize::MAX; + +/// Lower prototypes and `this` across the program. +pub fn deprototype(mut fns: Vec) -> Vec { + for f in &mut fns { + let mut next = max_val(f) + 1; + let self_v = take_self_param(f, &mut next); + if f.is_ctor { + produce_self(f, self_v, &mut next); + } + lower_calls(f, &mut next); + } + fns +} + +// ─── `this` becomes a parameter ──────────────────────────────────────────── + +/// Append `self`, then point every `push_this` at it. The `push_this` opcodes +/// vanish, replaced by a single load of the new parameter. +fn take_self_param(f: &mut RegFn, next: &mut usize) -> usize { + let self_idx = f.arg_count; + f.arg_count += 1; + + let bound: Vec = f + .code + .iter() + .filter(|i| i.op == OpCode::PushThis) + .flat_map(|i| i.dsts.iter().copied()) + .collect(); + if bound.is_empty() { + return NO_SELF; + } + + let self_v = fresh(next); + let code = std::mem::take(&mut f.code); + let mut out = Vec::with_capacity(code.len() + 1); + out.push(instr(OpCode::GetArg, ImmOperand::Arg(self_idx as u16), vec![self_v], vec![])); + for mut ri in code { + if ri.op == OpCode::PushThis { + continue; // its reads are redirected to `self` + } + for v in ri.args.iter_mut().chain(ri.dsts.iter_mut()) { + if bound.contains(v) { + *v = self_v; + } + } + out.push(ri); + } + f.code = out; + self_v +} + +// ─── 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 produce_self(f: &mut RegFn, self_v: usize, next: &mut usize) { + 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); + let mut cur = self_v; + + if !derived { + let cfn = fresh(next); + let proto = fresh(next); + let raw = fresh(next); + let obj = fresh(next); + out.push(instr(OpCode::SpecialObject, ImmOperand::U8(2), vec![cfn], vec![])); // running fn + out.push(instr(OpCode::GetField, proto_atom(), vec![proto], vec![cfn])); + out.push(instr(OpCode::Object, ImmOperand::None, vec![raw], vec![])); + out.push(instr(OpCode::CopyDataProperties, ImmOperand::U8(0), vec![obj], vec![raw, proto])); + cur = obj; + } + + for mut ri in code { + // Reads of `this` see the current object version. Only uses are + // repointed — `self`'s own load keeps its dead destination, so `cur`'s + // definition is not duplicated. + for v in ri.args.iter_mut() { + if *v == self_v { + *v = cur; + } + } + match ri.op { + // `this.f = v` becomes ordinary construction, threading a new + // version of the object. + OpCode::PutField if ri.args.len() == 2 && ri.args[1] == cur => { + let d = fresh(next); + let val = ri.args[0]; + out.push(instr(OpCode::DefineField, ri.imm, vec![d], vec![cur, val])); + cur = d; + } + // `super(...)` produced `this`; thread from its result. + OpCode::CallConstructor if derived && !ri.dsts.is_empty() => { + cur = ri.dsts[0]; + out.push(ri); + } + OpCode::Return | OpCode::ReturnUndef => { + out.push(instr(OpCode::Return, ImmOperand::None, vec![], vec![cur])); + } + _ => out.push(ri), + } + } + f.code = out; +} + +// ─── calls carry their receiver ──────────────────────────────────────────── + +fn lower_calls(f: &mut RegFn, next: &mut usize) { + let code = std::mem::take(&mut f.code); + let mut out = Vec::with_capacity(code.len()); + + // `get_field2`'s kept receiver echoes the object it read; record what each + // echo really is, and substitute at the end. + let mut kept: Vec<(usize, usize)> = Vec::new(); + + for ri in code { + match ri.op { + // `new` is gone, so the guard against calling without it is too. + OpCode::CheckCtor => {} + + // `super` is the parent constructor, recorded by declass as a + // field on the constructor object. + OpCode::GetSuper if ri.args.len() == 1 && !ri.dsts.is_empty() => { + out.push(instr(OpCode::GetField, parent_link(), vec![ri.dsts[0]], vec![ri.args[0]])); + } + + // `o.m(a) -> m(a, o)`. Register args are [call args.., func, recv]; + // the new call keeps its callee (func) last so declosure finds it. + OpCode::CallMethod | OpCode::TailCallMethod if ri.args.len() >= 2 => { + let n = ri.args.len(); + let recv = ri.args[n - 1]; + let func = ri.args[n - 2]; + let mut args = Vec::with_capacity(n); + args.extend_from_slice(&ri.args[..n - 2]); + args.push(recv); // self + args.push(func); // callee, last + let op = tail_or_plain(ri.op); + out.push(instr(op, ri.imm, ri.dsts, args)); + } + + // `new C(a)`: the constructor now builds and returns the object, so + // this is a plain call yielding it. `self` is undefined; the + // dropped `args[n-2]` was new.target. + OpCode::CallConstructor if ri.args.len() >= 2 => { + let n = ri.args.len(); + let func = ri.args[n - 1]; + let undef = fresh(next); + out.push(instr(OpCode::Undefined, ImmOperand::None, vec![undef], vec![])); + let mut args = Vec::with_capacity(n); + args.extend_from_slice(&ri.args[..n - 2]); + args.push(undef); // self + args.push(func); // callee, last + out.push(instr(OpCode::Call, ri.imm, ri.dsts, args)); + } + + // `get_field2` kept a receiver under the value it read. Receivers + // are explicit arguments now, so the kept copy is just the object, + // and this is an ordinary read. + OpCode::GetField2 if ri.dsts.len() == 2 && ri.args.len() == 1 => { + kept.push((ri.dsts[0], ri.args[0])); + out.push(instr(OpCode::GetField, ri.imm, vec![ri.dsts[1]], vec![ri.args[0]])); + } + + // A plain call has no receiver, so `this` is undefined. Insert it + // before the callee, which must stay last. + op if is_plain_call(op) && !ri.args.is_empty() => { + let n = ri.args.len(); + let callee = ri.args[n - 1]; + let undef = fresh(next); + out.push(instr(OpCode::Undefined, ImmOperand::None, vec![undef], vec![])); + let mut args = Vec::with_capacity(n + 1); + args.extend_from_slice(&ri.args[..n - 1]); + args.push(undef); // self + args.push(callee); // callee, last + out.push(instr(tail_or_plain(ri.op), ri.imm, ri.dsts, args)); + } + + _ => out.push(ri), + } + } + + // Point the echoed receivers at the object itself. + if !kept.is_empty() { + for ri in &mut out { + for v in ri.args.iter_mut().chain(ri.dsts.iter_mut()) { + if let Some((_, real)) = kept.iter().find(|(k, _)| k == v) { + *v = *real; + } + } + } + } + f.code = out; +} + +// ─── helpers ─────────────────────────────────────────────────────────────── + +fn is_plain_call(op: OpCode) -> bool { + use OpCode::*; + matches!(op, Call | Call0 | Call1 | Call2 | Call3 | TailCall) +} + +fn tail_or_plain(op: OpCode) -> OpCode { + use OpCode::*; + if matches!(op, TailCall | TailCallMethod) { + TailCall + } else { + Call + } +} + +fn instr(op: OpCode, imm: ImmOperand, dsts: Vec, args: Vec) -> RegInstr { + RegInstr { + dsts, + op, + imm, + args, + } +} + +fn fresh(next: &mut usize) -> usize { + let v = *next; + *next += 1; + v +} + +fn max_val(f: &RegFn) -> usize { + f.code + .iter() + .flat_map(|i| i.dsts.iter().chain(i.args.iter())) + .copied() + .max() + .unwrap_or(0) +} diff --git a/compiler/src/main.rs b/compiler/src/main.rs index 2f46a05..3c0ef82 100644 --- a/compiler/src/main.rs +++ b/compiler/src/main.rs @@ -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:#?}"); } diff --git a/compiler/src/regify.rs b/compiler/src/regify.rs new file mode 100644 index 0000000..584c6dd --- /dev/null +++ b/compiler/src/regify.rs @@ -0,0 +1,106 @@ +#![allow(non_snake_case)] + +use std::fmt::Debug; + +use crate::{ + flatten::{Capture, FlatFn}, + parse::{ImmOperand, Instr, JsValue, opcodes::OpCode}, +}; + +pub struct RegInstr { + pub dsts: Vec, + pub op: OpCode, + pub imm: ImmOperand, + pub args: Vec, +} + +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, + pub cpool: Vec, + pub code: Vec, + /// Set by `declass` when this function is a class constructor. + pub is_ctor: bool, +} + +fn regify_walk(code: &[Instr], var_count: usize) -> Vec { + 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) -> Vec { + 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() +} diff --git a/compiler/src/ssa.rs b/compiler/src/ssa.rs deleted file mode 100644 index 4861335..0000000 --- a/compiler/src/ssa.rs +++ /dev/null @@ -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 { - // 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::>(); - - blocks -} - -#[derive(Debug)] -struct SsaInstr { - dsts: Vec, - op: OpCode, - imm: ImmOperand, - args: Vec, -} - -fn ssa_walk(flat_fn: &FlatFn) -> Vec { - //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) { - 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:#?}"); - } -}