From af8524458737d306c82f7003244908867c9b8591 Mon Sep 17 00:00:00 2001 From: uditdey Date: Thu, 3 Sep 2026 20:20:58 +0000 Subject: [PATCH] New direction --- compiler/src/declass.rs | 336 --------------------------------- compiler/src/declass/mod.rs | 31 +++ compiler/src/declass/step_1.rs | 274 +++++++++++++++++++++++++++ compiler/src/declass/step_2.rs | 117 ++++++++++++ compiler/src/declosure.rs | 252 ------------------------- compiler/src/deprototype.rs | 274 --------------------------- compiler/src/flatten.rs | 6 +- compiler/src/main.rs | 21 +-- compiler/src/regify.rs | 192 +++++++------------ docs/declass_algo.txt | 193 +++++++++++++++++++ docs/declosure_algo.txt | 187 ++++++++++++++++++ docs/demutate_algo.txt | 210 +++++++++++++++++++++ 12 files changed, 1095 insertions(+), 998 deletions(-) delete mode 100644 compiler/src/declass.rs create mode 100644 compiler/src/declass/mod.rs create mode 100644 compiler/src/declass/step_1.rs create mode 100644 compiler/src/declass/step_2.rs delete mode 100644 compiler/src/declosure.rs delete mode 100644 compiler/src/deprototype.rs create mode 100644 docs/declass_algo.txt create mode 100644 docs/declosure_algo.txt create mode 100644 docs/demutate_algo.txt diff --git a/compiler/src/declass.rs b/compiler/src/declass.rs deleted file mode 100644 index 4a842cc..0000000 --- a/compiler/src/declass.rs +++ /dev/null @@ -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) -> Vec { - // Parameter counts, read before anything changes them. - let arity: Vec = 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 = 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, - 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 = 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, 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 { - match instr.imm { - ImmOperand::AtomU8 { arg, .. } => Some(arg), - _ => None, - } -} diff --git a/compiler/src/declass/mod.rs b/compiler/src/declass/mod.rs new file mode 100644 index 0000000..a3a2d1e --- /dev/null +++ b/compiler/src/declass/mod.rs @@ -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, _atoms: &[Atom]) -> Vec { + // Read the constructor <-> field-initialiser links before step 1 rewrites + // the class builds that carry them. + let field_inits = step_1::field_init_links(®_fns); + + let mut reg_fns: Vec = reg_fns.into_iter().map(step_1::rewrite).collect(); + + step_2::splice(&mut reg_fns, &field_inits); + reg_fns +} diff --git a/compiler/src/declass/step_1.rs b/compiler/src/declass/step_1.rs new file mode 100644 index 0000000..0141b53 --- /dev/null +++ b/compiler/src/declass/step_1.rs @@ -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 { + 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(®_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 = 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, + 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, 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 { + 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 { + 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)) +} diff --git a/compiler/src/declass/step_2.rs b/compiler/src/declass/step_2.rs new file mode 100644 index 0000000..0709fbf --- /dev/null +++ b/compiler/src/declass/step_2.rs @@ -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 and runs with a call (declass_algo.txt, step 2): +//! +//! get_var_ref_check ; 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 +//! `` 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 +//! `` 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 = ""; + +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 { + 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, ctor_fn: &mut RegFn) -> Vec { + 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 .. 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 `` 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 + ) +} diff --git a/compiler/src/declosure.rs b/compiler/src/declosure.rs deleted file mode 100644 index c38d602..0000000 --- a/compiler/src/declosure.rs +++ /dev/null @@ -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) -> Vec { - // 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> = 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 { - 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], 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 { - 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; -} diff --git a/compiler/src/deprototype.rs b/compiler/src/deprototype.rs deleted file mode 100644 index a28c124..0000000 --- a/compiler/src/deprototype.rs +++ /dev/null @@ -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) -> Vec { - 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 { - let self_param = f.arg_count; - f.arg_count += 1; - - // The values `push_this` produced. - let this_values: Vec = 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, 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 { - 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, fresh: &mut Fresh) -> usize { - let v = fresh.new_value(); - out.push(RegInstr::new(OpCode::Undefined, ImmOperand::None, vec![v], vec![])); - v -} diff --git a/compiler/src/flatten.rs b/compiler/src/flatten.rs index c1910a4..6405a03 100644 --- a/compiler/src/flatten.rs +++ b/compiler/src/flatten.rs @@ -4,7 +4,7 @@ use crate::parse::{ Atom, AtomRef, ClosureKind, Function, Instr, JsString, JsValue, QuickJSBinary, atom_names, }; -#[derive(Debug, 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, // adds names to args and locals, one for each arg, then one for each local, in one vec pub captures: Vec, pub cpool: Vec, pub code: Vec, @@ -71,6 +72,7 @@ fn walk(flat_fns: &mut Vec, 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, diff --git a/compiler/src/main.rs b/compiler/src/main.rs index 3c0ef82..400bdcd 100644 --- a/compiler/src/main.rs +++ b/compiler/src/main.rs @@ -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:#?}");*/ } diff --git a/compiler/src/regify.rs b/compiler/src/regify.rs index 51fac36..c0ff3fa 100644 --- a/compiler/src/regify.rs +++ b/compiler/src/regify.rs @@ -14,6 +14,17 @@ pub struct RegInstr { pub args: Vec, } +impl RegInstr { + pub fn new(op: OpCode, imm: ImmOperand, dsts: Vec, args: Vec) -> 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, pub captures: Vec, pub cpool: Vec, pub code: Vec, - /// 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 { - 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) -> 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() -} - -// ─── shared by the de* passes ────────────────────────────────────────────── - -impl RegInstr { - pub fn new(op: OpCode, imm: ImmOperand, dsts: Vec, args: Vec) -> 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() } diff --git a/docs/declass_algo.txt b/docs/declass_algo.txt new file mode 100644 index 0000000..e56d9e3 --- /dev/null +++ b/docs/declass_algo.txt @@ -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 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 + through the next call_method): + + get_var_ref_check ; 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 "") that captures + +which in the example is: + + name: "", + captures: [ Capture { name: "", ... } ], + +REPLACE with: + + name: "Counter", + captures: [], + + name becomes the class name from step 1. + Remove the 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 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. diff --git a/docs/declosure_algo.txt b/docs/declosure_algo.txt new file mode 100644 index 0000000..2886ecf --- /dev/null +++ b/docs/declosure_algo.txt @@ -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() [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, , 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. diff --git a/docs/demutate_algo.txt b/docs/demutate_algo.txt new file mode 100644 index 0000000..3145023 --- /dev/null +++ b/docs/demutate_algo.txt @@ -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 ; ( ; DefineField @F )+ + mutation run ( ; ; 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: + + // 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 "", 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 "", 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.