Big progress by Claude

This commit is contained in:
2026-09-25 19:31:15 +00:00
parent 183c4e35c9
commit e93ce94ac7
15 changed files with 1577 additions and 470 deletions
+95
View File
@@ -0,0 +1,95 @@
//! Decide, for each local and arg, whether emit writes it as a value or as a
//! pointer.
//!
//! A var used only by the function that owns it is a value. A var that also
//! goes into a sink is shared, and a shared var is a pointer. The sinks are a
//! call (callee, `this` or an arg), a field write (the value written, which
//! includes a capture written into a closure's environment), and a copy into
//! another value. A read is a copy, so an object var that is read is shared:
//! JS objects are references.
//!
//! A primitive cannot be changed in place, so sharing one is harmless and it
//! stays a value. The exception is a closure's environment: there the variable
//! itself is shared, and a later assignment must be seen through it.
//!
//! The result is one flag per var of each function: its locals, then its args.
//! A var's index is its value id.
use crate::{
infer::{InferResult, TypeCache, types::Type},
libqjs::parse::opcodes::OpCode,
regify::{RegFn, RegInstr},
};
pub fn find_by_ref(reg_fns: &[RegFn], cache: &TypeCache) -> Vec<Vec<bool>> {
let mut by_ref: Vec<Vec<bool>> = reg_fns
.iter()
.map(|f| vec![false; f.var_count + f.arg_count])
.collect();
// A var is a pointer if it needs to be one in any of its function's
// variants: emit gives all the variants the same answer.
for (req, res) in cache.results() {
mark(&reg_fns[req.fn_id], res, &mut by_ref[req.fn_id]);
}
by_ref
}
fn mark(f: &RegFn, res: &InferResult, by_ref: &mut [bool]) {
let n_vars = by_ref.len();
let code = || {
f.blocks
.iter()
.flat_map(|b| f.code[b.start..b.end].iter().chain(&b.postfix))
};
// A read copies a var into a new value. Map each value to the var it is.
let mut var_of: Vec<Option<usize>> = (0..f.val_count)
.map(|v| (v < n_vars).then_some(v))
.collect();
for instr in code() {
if instr.op == OpCode::SetLoc && instr.args[0] < n_vars && instr.dsts[0] >= n_vars {
var_of[instr.dsts[0]] = Some(instr.args[0]);
}
}
for instr in code() {
let into_env = instr.op == OpCode::DefineField
&& matches!(res.val_types[instr.args[1]], Type::Closure { .. });
for &v in sinks(instr) {
if let Some(var) = var_of[v] {
if into_env || !is_primitive(&res.val_types[var]) {
by_ref[var] = true;
}
}
}
}
}
/// The operands of `instr` that go somewhere other than the function itself.
fn sinks(instr: &RegInstr) -> &[usize] {
use OpCode::*;
match instr.op {
Call | Call0 | Call1 | Call2 | Call3 | TailCall | CallMethod | TailCallMethod
| CallConstructor => &instr.args,
// args are [value, object]: only the value goes into the object.
DefineField => &instr.args[..1],
// A copy into another value: a read, a copy into another var, or a phi.
SetLoc if instr.dsts[0] != instr.args[0] => &instr.args,
_ => &[],
}
}
fn is_primitive(ty: &Type) -> bool {
matches!(
ty,
Type::Unknown | Type::Undefined | Type::Bool | Type::Int | Type::Float | Type::Native(_)
)
}
+4
View File
@@ -0,0 +1,4 @@
//! Analyses over the flattened program. They do not change the code; their
//! results are read by the later stages.
pub mod by_ref;
+38
View File
@@ -0,0 +1,38 @@
//! Every builtin, in one place.
//!
//! A builtin is ordinary JS in [`PRELUDE`] (`lib/prelude.js`), which is
//! compiled together with the program, in front of it. Each builtin is then a
//! module binding like the program's own, and every stage handles it as user
//! code: infer walks only the ones that are called, and emit compiles them
//! like any other function.
//!
//! Where a builtin needs something JS cannot say, such as IO, its body calls a
//! C function. That is written as a call to a global named `__c_<name>`, and
//! [`NATIVES`] declares each one: what it is called in C, and the type of what
//! it returns. Such a global is the only kind the program may read.
use crate::infer::types::Type;
pub const PRELUDE: &[u8] = include_bytes!("../../lib/prelude.js");
/// A C function that builtins call.
pub struct Native {
/// The global a builtin calls it by.
pub js_name: &'static str,
/// The function emit calls.
pub c_name: &'static str,
/// What the call returns, from the types of its args in source order. A
/// native does not write its args.
pub ret: fn(&[Type]) -> Type,
}
pub static NATIVES: &[Native] = &[Native {
js_name: "__c_print",
c_name: "jsrt_print",
ret: |_| Type::Undefined,
}];
/// The native a global of this name calls, if it is one.
pub fn native(js_name: &str) -> Option<usize> {
NATIVES.iter().position(|n| n.js_name == js_name)
}
+501
View File
@@ -0,0 +1,501 @@
//! C code generation.
//!
//! One struct per object shape, and one function per inferred
//! function-and-argument pair. The body is walked the same way inference walks
//! it — block by block, instruction by instruction — with one difference:
//! inference skips an instruction that defines nothing, but jumps and returns
//! define nothing and still have to be emitted.
//!
//! A function value is compile-time only: every call site knows the callee, so
//! calls go straight to the monomorphized function by name. The function is
//! never stored, so a closure's struct is its captured environment alone.
use std::collections::HashMap;
use std::fmt::Write;
use crate::infer::types::Type;
use crate::infer::{InferRequest, InferResult, TypeCache};
use crate::libqjs::parse::{Atom, AtomRef, ImmOperand, JsValue, atom_to_str, opcodes::OpCode};
use crate::model::{EXTRA_ARGS, FIELD_PROTO};
use crate::regify::{BasicBlock, RegFn, RegInstr};
/// The C name of each monomorphized variant, keyed by its function and argument
/// types, so a call site and a definition agree.
type Names = HashMap<(usize, Vec<Type>), String>;
pub fn emit(cache: &TypeCache, reg_fns: &[RegFn], atoms: &[Atom]) -> String {
let mut out = String::new();
out.push_str("// Generated by the jsrt compiler. Do not edit.\n\n");
out.push_str("#include <stdbool.h>\n#include <stdint.h>\n\n");
out.push_str("// Placeholders, until the lattice can say more than this.\n");
out.push_str("typedef struct { char _; } undefined_t;\n");
out.push_str("typedef struct { char _; } any_t;\n");
out.push_str("typedef struct { char _; } empty_t;\n\n");
let names = mangle(cache, reg_fns, atoms);
emit_shapes(cache, atoms, &mut out);
out.push_str("// ── prototypes ──\n");
for (req, res) in sorted(cache) {
let _ = writeln!(out, "{};", signature(req, res, cache, &names));
}
out.push('\n');
for (req, res) in sorted(cache) {
emit_fn(req, res, reg_fns, cache, atoms, &names, &mut out);
}
out
}
/// Results in a stable order, so the same program emits the same file.
fn sorted(cache: &TypeCache) -> Vec<(&InferRequest, &InferResult)> {
let mut rs: Vec<_> = cache.results().collect();
rs.sort_by_key(|(req, _)| (req.fn_id, format!("{:?}", req.arg_types)));
rs
}
/// One C name per function-and-argument pair: the name, its id, and a per-
/// function variant index.
fn mangle(cache: &TypeCache, reg_fns: &[RegFn], atoms: &[Atom]) -> Names {
let mut names = HashMap::new();
let mut seen: HashMap<usize, usize> = HashMap::new();
for (req, _) in sorted(cache) {
let variant = seen.entry(req.fn_id).or_insert(0);
let base = sanitize(&atom_to_str(&reg_fns[req.fn_id].name, atoms));
let key = (req.fn_id, req.arg_types.clone());
names.insert(key, format!("{}_{}_{}", base, req.fn_id, variant));
*variant += 1;
}
names
}
fn name_of(fn_id: usize, arg_types: &[Type], names: &Names) -> String {
names[&(fn_id, arg_types.to_vec())].clone()
}
fn signature(req: &InferRequest, res: &InferResult, cache: &TypeCache, names: &Names) -> String {
let params: Vec<String> = req
.arg_types
.iter()
.enumerate()
.map(|(i, ty)| format!("{} a{i}", c_type(ty, cache)))
.collect();
format!(
"{} {}({})",
c_type(&res.ret_type, cache),
name_of(req.fn_id, &req.arg_types, names),
params.join(", ")
)
}
fn emit_fn(
req: &InferRequest,
res: &InferResult,
reg_fns: &[RegFn],
cache: &TypeCache,
atoms: &[Atom],
names: &Names,
out: &mut String,
) {
let f = &reg_fns[req.fn_id];
let _ = writeln!(out, "// {}", atom_to_str(&f.name, atoms));
let _ = writeln!(out, "{} {{", signature(req, res, cache, names));
// Every value gets a slot. Locals are not renamed, so a local and the
// values flowing through it share one.
for (v, ty) in res.val_types.iter().enumerate() {
let _ = writeln!(out, " {} v{v};", c_type(ty, cache));
}
if !res.val_types.is_empty() {
out.push('\n');
}
for (bi, block) in f.blocks.iter().enumerate() {
let _ = writeln!(out, "L{bi}:;");
// Stack values that outlive the block are copied into the successor's
// slots, and that has to happen before control leaves the block — so
// the terminator is held back until the copies are out.
let body = &f.code[block.start..block.end];
let (body, terminator) = match body.last() {
Some(last) if is_terminator(last.op) => (&body[..body.len() - 1], Some(last)),
_ => (body, None),
};
for instr in body {
emit_instr(instr, block, res, f, reg_fns, cache, atoms, names, out);
}
for instr in &block.postfix {
emit_instr(instr, block, res, f, reg_fns, cache, atoms, names, out);
}
if let Some(instr) = terminator {
emit_instr(instr, block, res, f, reg_fns, cache, atoms, names, out);
}
}
out.push_str("}\n\n");
}
#[allow(clippy::too_many_arguments)]
fn emit_instr(
instr: &RegInstr,
block: &BasicBlock,
res: &InferResult,
f: &RegFn,
reg_fns: &[RegFn],
cache: &TypeCache,
atoms: &[Atom],
names: &Names,
out: &mut String,
) {
use OpCode::*;
let a = |i: usize| format!("v{}", instr.args[i]);
// A binary operand sits under its partner on the stack, so the left-hand
// side of the source expression is the *later* arg.
let bin = |op: &str| format!("{} {} {}", a(1), op, a(0));
let set = |expr: String, out: &mut String| {
for d in &instr.dsts {
let _ = writeln!(out, " v{d} = {expr};");
}
};
match instr.op {
Nop | Drop => {}
PushMinus1 => set("-1".into(), out),
Push0 => set("0".into(), out),
Push1 => set("1".into(), out),
Push2 => set("2".into(), out),
Push3 => set("3".into(), out),
Push4 => set("4".into(), out),
Push5 => set("5".into(), out),
Push6 => set("6".into(), out),
Push7 => set("7".into(), out),
PushI8 | PushI16 | PushI32 => {
let v = match instr.imm {
ImmOperand::I8(n) => n as i64,
ImmOperand::I16(n) => n as i64,
ImmOperand::I32(n) => n as i64,
_ => unreachable!(),
};
set(v.to_string(), out);
}
PushTrue => set("true".into(), out),
PushFalse => set("false".into(), out),
PushConst | PushConst8 => {
let ImmOperand::Const(k) = instr.imm else {
unreachable!()
};
let expr = match &f.cpool[k as usize] {
JsValue::Int(n) => n.to_string(),
JsValue::Float(x) => format!("{x:?}"),
JsValue::BoolTrue => "true".into(),
JsValue::BoolFalse => "false".into(),
_ => unreachable!(),
};
set(expr, out);
}
Add => set(bin("+"), out),
Sub => set(bin("-"), out),
Mul => set(bin("*"), out),
Mod => set(bin("%"), out),
Div => set(format!("(double){} / (double){}", a(1), a(0)), out),
Neg => set(format!("-{}", a(0)), out),
Plus => set(a(0), out),
Inc => set(format!("{} + 1", a(0)), out),
Dec => set(format!("{} - 1", a(0)), out),
Lt => set(bin("<"), out),
Lte => set(bin("<="), out),
Gt => set(bin(">"), out),
Gte => set(bin(">="), out),
Eq | StrictEq => set(bin("=="), out),
Neq | StrictNeq => set(bin("!="), out),
Not => set(format!("!{}", a(0)), out),
Dup | SetLoc | SetName => {
let src = instr.args[0];
for d in &instr.dsts {
emit_copy(*d, &res.val_types[*d], src, &res.val_types[src], atoms, out);
}
}
Undefined => set("(undefined_t){0}".into(), out),
GetArg0 => set("a0".into(), out),
GetArg1 => set("a1".into(), out),
GetArg2 => set("a2".into(), out),
GetArg3 => set("a3".into(), out),
GetArg => {
let ImmOperand::Arg(k) = instr.imm else {
unreachable!()
};
set(format!("a{k}"), out);
}
// A new object, or a new closure: both start with no fields. A
// closure's function is compile-time only, so it leaves no trace here.
Object | Fclosure | Fclosure8 => {
let d = instr.dsts[0];
let _ = writeln!(
out,
" v{d} = ({}){{0}};",
c_type(&res.val_types[d], cache)
);
}
DefineField => {
let ImmOperand::Atom(atom) = instr.imm else {
unreachable!()
};
let obj = instr.args[1];
let d = instr.dsts[0];
// A write-back names the object itself, so there is nothing to
// carry over. Otherwise the result is a different shape, and C will
// not assign one struct type to another — so the fields that
// survive are copied one at a time.
if d != obj {
for (carried, _) in res.val_types[obj].fields() {
if carried == atom {
continue;
}
let cn = field_name(carried, atoms);
let _ = writeln!(out, " v{d}.{cn} = v{obj}.{cn};");
}
}
let _ = writeln!(out, " v{d}.{} = {};", field_name(atom, atoms), a(0));
}
GetField => {
let ImmOperand::Atom(atom) = instr.imm else {
unreachable!()
};
set(format!("{}.{}", a(0), field_name(atom, atoms)), out);
}
Call | Call0 | Call1 | Call2 | Call3 | TailCall | CallMethod | TailCallMethod => {
emit_call(instr, res, reg_fns, names, out);
}
Return | ReturnAsync => {
let _ = writeln!(out, " return {};", a(0));
}
ReturnUndef => {
let _ = writeln!(out, " return ({}){{0}};", c_type(&res.ret_type, cache));
}
Goto | Goto8 | Goto16 => {
let _ = writeln!(out, " goto L{};", block.succ[0]);
}
IfFalse | IfFalse8 => {
let _ = writeln!(out, " if (!{}) goto L{};", a(0), block.succ[0]);
if let Some(fallthrough) = block.succ.get(1) {
let _ = writeln!(out, " goto L{fallthrough};");
}
}
IfTrue | IfTrue8 => {
let _ = writeln!(out, " if ({}) goto L{};", a(0), block.succ[0]);
if let Some(fallthrough) = block.succ.get(1) {
let _ = writeln!(out, " goto L{fallthrough};");
}
}
other => {
let _ = writeln!(out, " /* TODO {other:?} */");
}
}
}
/// Move one value into another's slot.
///
/// A local's slot is declared with the type it ends up holding, so an earlier
/// assignment to it can be a different shape. C will not assign one struct type
/// to another, so the fields the two shapes share are carried over one at a
/// time. Fields the destination does not have yet are simply not set.
fn emit_copy(
dst: usize,
dst_ty: &Type,
src: usize,
src_ty: &Type,
atoms: &[Atom],
out: &mut String,
) {
if dst_ty == src_ty || !is_object(dst_ty) || !is_object(src_ty) {
let _ = writeln!(out, " v{dst} = v{src};");
return;
}
for (name, _) in src_ty.fields() {
if !dst_ty.fields().any(|(n, _)| n == name) {
continue;
}
let n = field_name(name, atoms);
let _ = writeln!(out, " v{dst}.{n} = v{src}.{n};");
}
}
/// Control leaves the block at these, so nothing may be emitted after one.
fn is_terminator(op: OpCode) -> bool {
use OpCode::*;
matches!(
op,
Goto | Goto8
| Goto16
| IfFalse
| IfFalse8
| IfTrue
| IfTrue8
| Return
| ReturnUndef
| ReturnAsync
)
}
/// Something with a struct representation whose fields can be copied. A closure
/// counts: its representation is its environment.
fn is_object(ty: &Type) -> bool {
matches!(
ty,
Type::Empty | Type::WithField { .. } | Type::Closure { .. }
)
}
/// The frame is the one inference reads: `[this,] callee, arg0 .. argN`, with
/// `args` in pop order. The request is rebuilt exactly as inference built it, so
/// the variant is an exact lookup, and the callee is emitted by name.
fn emit_call(
instr: &RegInstr,
res: &InferResult,
reg_fns: &[RegFn],
names: &Names,
out: &mut String,
) {
use OpCode::*;
let is_method = matches!(instr.op, CallMethod | TailCallMethod);
let n = instr.args.len();
let (callee_idx, this, arg_end) = if is_method {
(instr.args[n - 2], Some(instr.args[n - 1]), n - 2)
} else {
(instr.args[n - 1], None, n - 1)
};
let Type::Closure { fn_id, .. } = res.val_types[callee_idx] else {
unreachable!()
};
let declared = reg_fns[fn_id].arg_count - EXTRA_ARGS;
let mut arg_types = Vec::with_capacity(declared + EXTRA_ARGS);
let mut args = Vec::with_capacity(declared + EXTRA_ARGS);
let mut push = |ty: Type, expr: String| {
arg_types.push(ty);
args.push(expr);
};
for i in 0..declared {
match arg_end.checked_sub(i + 1) {
Some(k) if i < arg_end => {
let v = instr.args[k];
push(res.val_types[v].clone(), format!("v{v}"));
}
_ => push(Type::Undefined, "(undefined_t){0}".to_string()),
}
}
match this {
Some(v) => push(res.val_types[v].clone(), format!("v{v}")),
None => push(Type::Undefined, "(undefined_t){0}".to_string()),
}
push(res.val_types[callee_idx].clone(), format!("v{callee_idx}"));
let call = format!("{}({})", name_of(fn_id, &arg_types, names), args.join(", "));
if instr.dsts.is_empty() {
let _ = writeln!(out, " {call};");
} else {
for d in &instr.dsts {
let _ = writeln!(out, " v{d} = {call};");
}
}
}
fn emit_shapes(cache: &TypeCache, atoms: &[Atom], out: &mut String) {
let mut shapes: Vec<_> = cache.shapes().collect();
shapes.sort_by_key(|(_, id)| id.0);
for (ty, id) in shapes {
let _ = writeln!(out, "typedef struct {{");
for (name, value) in ty.fields() {
let _ = writeln!(
out,
" {} {};",
c_type(value, cache),
field_name(name, atoms)
);
}
let _ = writeln!(out, "}} Shape{};\n", id.0);
}
}
fn c_type(ty: &Type, cache: &TypeCache) -> String {
match ty {
Type::Int => "int32_t".to_string(),
Type::Float => "double".to_string(),
Type::Bool => "bool".to_string(),
Type::Undefined => "undefined_t".to_string(),
// Unknown means the value was never inferred: dead code, or a known
// gap. It has no real type, so it renders as undefined.
Type::Unknown => "undefined_t".to_string(),
Type::Any => "any_t".to_string(),
// A closure is its environment; the function is compile-time only.
Type::Closure { env, .. } => c_type(env, cache),
// An object with no fields carries nothing, so it needs no struct.
Type::Empty => "empty_t".to_string(),
// A native is called by name, so its value carries nothing.
Type::Native(_) => "empty_t".to_string(),
Type::WithField { .. } => format!("Shape{}", cache.shape_id(ty).unwrap().0),
}
}
fn field_name(name: AtomRef, atoms: &[Atom]) -> String {
if name == FIELD_PROTO {
return "proto".to_string();
}
match name {
AtomRef::TaggedInt(n) => format!("i{n}"),
_ => sanitize(&atom_to_str(&name, atoms)),
}
}
fn sanitize(s: &str) -> String {
let mut out: String = s
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
if !out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
out.insert(0, '_');
}
out
}
+43 -1
View File
@@ -1,4 +1,6 @@
use crate::libqjs::parse::{Atom, AtomRef, ClosureVar, Function, Instr, JsValue, QuickJSBinary};
use crate::libqjs::parse::{
Atom, AtomRef, ClosureVar, Function, ImmOperand, Instr, JsValue, QuickJSBinary, opcodes::OpCode,
};
#[derive(Debug)]
pub struct FlatFn {
@@ -13,9 +15,49 @@ pub fn flatten(bin: QuickJSBinary) -> (Vec<FlatFn>, Vec<Vec<ClosureVar>>, Vec<At
let mut fns = vec![];
let mut captures = vec![];
walk(bin.root_mod.root_fn, &mut fns, &mut captures);
append_entry(&mut fns, &mut captures);
(fns, captures, bin.atoms)
}
/// Append the synthetic program entry, the host the runtime would provide,
/// written in the IR so the ordinary passes handle it. It drives the module
/// body `<eval>` (fn 0): build its environment record, run the link phase
/// (`this = true`), then the evaluate phase (`this = false`).
///
/// `fclosure8` of fn 0 lets `declosure` build the record from `<eval>`'s own
/// captures — the module bindings — so no stage needs bespoke module knowledge.
/// The closure is made once and kept in local 0, so both phases run on the one
/// record and the link phase's writes reach the evaluate phase. `call_method`
/// carries the phase flag as the receiver (`this`); a plain call could not set
/// it.
fn append_entry(fns: &mut Vec<FlatFn>, captures: &mut Vec<Vec<ClosureVar>>) {
let mk = |pc, op, imm| Instr { pc, op, imm };
let code = vec![
mk(0, OpCode::Fclosure8, ImmOperand::Const(0)),
mk(1, OpCode::PutLoc, ImmOperand::Loc(0)),
mk(2, OpCode::PushTrue, ImmOperand::None),
mk(3, OpCode::GetLoc, ImmOperand::Loc(0)),
mk(4, OpCode::CallMethod, ImmOperand::Npop(0)),
mk(5, OpCode::Drop, ImmOperand::None),
mk(6, OpCode::PushFalse, ImmOperand::None),
mk(7, OpCode::GetLoc, ImmOperand::Loc(0)),
mk(8, OpCode::CallMethod, ImmOperand::Npop(0)),
mk(9, OpCode::Drop, ImmOperand::None),
mk(10, OpCode::ReturnUndef, ImmOperand::None),
];
fns.push(FlatFn {
name: AtomRef::Predefined(0),
arg_count: 0,
// Local 0 holds the closure of `<eval>`.
var_count: 1,
cpool: vec![JsValue::FnById(0)],
code,
});
captures.push(vec![]);
}
fn walk(func: Function, flat_fns: &mut Vec<FlatFn>, captures: &mut Vec<Vec<ClosureVar>>) -> usize {
let id = flat_fns.len();
-390
View File
@@ -1,390 +0,0 @@
#![allow(non_snake_case)]
use std::collections::HashMap;
use crate::{
infer::Type::ReturnOf,
libqjs::parse::{Atom, AtomRef, ImmOperand, JsString, JsValue, atom_names, opcodes::OpCode},
model::{EXTRA_ARGS, FIELD_FN, FIELD_PROTO, MAX_PROTO_DEPTH},
regify::{RegFn, RegInstr},
};
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum FnKind {
Id(usize),
DefineField(AtomRef),
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum Type {
Unknown,
Undefined,
Bool,
Int,
Float,
Empty,
Fn(usize),
ReturnOf((FnKind, Vec<Type>)),
Any,
}
/// Type lattice join rules:
/// Unknown * T = T
/// Any * T = Any
/// T * T = T
/// T * U = Any
fn join_ty(a: &Type, b: &Type) -> Type {
match (a, b) {
(Type::Unknown, t) | (t, Type::Unknown) => t.clone(),
(Type::Any, _) | (_, Type::Any) => Type::Any,
(a, b) if a == b => a.clone(),
_ => Type::Any,
}
}
/// Result of looking a field up on one object, ignoring its prototype.
enum Lookup {
Found(Type),
Missing,
Any,
}
/// Look `field` up on `ty`. A miss on the object itself follows the prototype
/// link and retries, which is how a method defined on a prototype is found
/// from an instance.
fn get_field(ty: &Type, field: AtomRef, cache: &mut TypeCache) -> Type {
let mut current = ty.clone();
for _ in 0..MAX_PROTO_DEPTH {
match own_field(&current, field, cache) {
Lookup::Found(t) => return t,
Lookup::Any => return Type::Any,
Lookup::Missing => match own_field(&current, FIELD_PROTO, cache) {
Lookup::Found(proto) => current = proto,
_ => return Type::Undefined,
},
}
}
// The chain loops, or is deeper than anything real. Stop guessing.
Type::Any
}
/// Walk one object's own field chain, without following its prototype.
fn own_field(ty: &Type, field: AtomRef, cache: &mut TypeCache) -> Lookup {
let mut current = ty.clone();
loop {
match current {
Type::Any => return Lookup::Any,
Type::ReturnOf((fn_kind, args)) => match fn_kind {
FnKind::DefineField(atom) => {
debug_assert!(args.len() == 2);
if atom == field {
return Lookup::Found(args[0].clone());
}
current = args[1].clone();
}
FnKind::Id(id) => {
match cache.get(&InferRequest {
fn_id: id,
arg_types: args.clone(),
}) {
Some(info) => current = info.ret_type.clone(),
// Not inferred yet, so the shape is unknown rather
// than impossible.
None => return Lookup::Any,
}
}
},
_ => return Lookup::Missing,
}
}
}
/// A call frame, in the order the operands were pushed:
///
/// [this,] callee, arg0 .. arg_n
///
/// `instr.args` is in pop order, so the callee is last for a plain call, and
/// second to last for a method call, which carries a receiver beneath it. A
/// plain call has no receiver slot, so its `this` is undefined.
struct CallFrame {
/// The closure object being called. It is also the callee's environment.
callee: Type,
this: Type,
/// The actual arguments, back in source order.
args: Vec<Type>,
}
fn call_frame(instr: &RegInstr, val_types: &[Type]) -> CallFrame {
let is_method = matches!(instr.op, OpCode::CallMethod | OpCode::TailCallMethod);
let n = instr.args.len();
let (callee, this, rest) = if is_method {
(
val_types[instr.args[n - 2]].clone(),
val_types[instr.args[n - 1]].clone(),
&instr.args[..n - 2],
)
} else {
(
val_types[instr.args[n - 1]].clone(),
Type::Undefined,
&instr.args[..n - 1],
)
};
CallFrame {
callee,
this,
args: rest.iter().rev().map(|a| val_types[*a].clone()).collect(),
}
}
/// The parameters a lowered function sees: its own, then `this`, then the
/// environment. The actuals are padded or truncated to the declared arity, so
/// the last two always land at a fixed index whatever the caller passed.
fn arg_types_for(callee: &RegFn, frame: &CallFrame) -> Vec<Type> {
let declared = callee.arg_count.saturating_sub(EXTRA_ARGS);
let mut out = Vec::with_capacity(declared + EXTRA_ARGS);
for i in 0..declared {
out.push(frame.args.get(i).cloned().unwrap_or(Type::Undefined));
}
out.push(frame.this.clone());
out.push(frame.callee.clone());
out
}
#[derive(Debug, Hash, PartialEq, Eq)]
pub struct InferRequest {
pub fn_id: usize,
pub arg_types: Vec<Type>,
}
#[derive(Debug)]
pub struct InferResult {
pub val_types: Vec<Type>,
pub ret_type: Type,
}
pub type TypeCache = HashMap<InferRequest, InferResult>;
pub fn infer_types(reg_fns: &[RegFn], atoms: &[Atom]) -> TypeCache {
// Find main fn
let main_fn_id = reg_fns
.iter()
.position(|f| atom_to_str(&f.name, atoms) == "main")
.unwrap();
let mut cache = TypeCache::with_capacity(reg_fns.len());
// main is called by nobody, so every parameter it has — including this
// and its environment — is undefined.
let req = InferRequest {
fn_id: main_fn_id,
arg_types: vec![Type::Undefined; reg_fns[main_fn_id].arg_count],
};
let res = walk_fn(&req, reg_fns, &mut cache);
cache.insert(req, res);
cache
}
fn walk_fn(req: &InferRequest, reg_fns: &[RegFn], cache: &mut TypeCache) -> InferResult {
use OpCode::*;
let f = &reg_fns[req.fn_id];
let mut val_types = vec![Type::Unknown; f.val_count];
let mut ret_type = Type::Unknown;
for block in &f.blocks {
let code = &f.code[block.start..block.end];
let code = code.iter().chain(&block.postfix);
for instr in code {
let is_ret = matches!(instr.op, Return | ReturnAsync | ReturnUndef);
if instr.dsts.is_empty() && !is_ret {
continue;
}
// TODO: Some of these ops may not every show up by this point
let ty = match instr.op {
// ──────────────────────────────────── Literals ────────────────────────────────────
PushMinus1 | Push0 | Push1 | Push2 | Push3 | Push4 | Push5 | Push6 | Push7
| PushI8 | PushI16 | PushI32 => Type::Int,
PushTrue | PushFalse => Type::Bool,
PushConst | PushConst8 => match instr.imm {
ImmOperand::Const(k) => match f.cpool.get(k as usize) {
Some(JsValue::Int(_)) => Type::Int,
Some(JsValue::Float(_)) => Type::Float,
Some(JsValue::BoolTrue) | Some(JsValue::BoolFalse) => Type::Bool,
Some(JsValue::FnById(id)) => Type::Fn(*id),
_ => unreachable!(),
},
_ => unreachable!(),
},
// ─────────────────────────────────── Arithmetic ───────────────────────────────────
Add | Sub | Mul | Mod | Pow => {
match (&val_types[instr.args[0]], &val_types[instr.args[1]]) {
(Type::Int, Type::Int) => Type::Int,
(Type::Float, Type::Float)
| (Type::Int, Type::Float)
| (Type::Float, Type::Int) => Type::Float,
_ => Type::Any,
}
}
Neg | Plus | Inc | Dec => match val_types[instr.args[0]] {
Type::Int => Type::Int,
Type::Float => Type::Float,
_ => Type::Any,
},
Div => Type::Float,
Lt | Lte | Gt | Gte | Eq | Neq | StrictEq | StrictNeq | Not => Type::Bool,
// ────────────────────────────────── Value Movers ──────────────────────────────────
Dup | SetLoc | SetName => val_types[instr.args[0]].clone(),
Undefined => Type::Undefined,
// ──────────────────────────────────── Arguments ───────────────────────────────────
GetArg0 => req.arg_types.first().cloned().unwrap_or(Type::Undefined),
GetArg1 => req.arg_types.get(1).cloned().unwrap_or(Type::Undefined),
GetArg2 => req.arg_types.get(2).cloned().unwrap_or(Type::Undefined),
GetArg3 => req.arg_types.get(3).cloned().unwrap_or(Type::Undefined),
GetArg => match instr.imm {
ImmOperand::Arg(k) => req
.arg_types
.get(k as usize)
.cloned()
.unwrap_or(Type::Undefined),
_ => unreachable!(),
},
// ──────────────────────────────────── Objects ─────────────────────────────────────
Object => Type::Empty,
DefineField => {
let field = match instr.imm {
ImmOperand::Atom(atom) => atom,
_ => unreachable!(),
};
let arg0_ty = val_types[instr.args[0]].clone();
let arg1_ty = val_types[instr.args[1]].clone();
ReturnOf((FnKind::DefineField(field), vec![arg0_ty, arg1_ty]))
}
GetField => {
let field = match instr.imm {
ImmOperand::Atom(atom) => atom,
_ => unreachable!(),
};
let ty = val_types[instr.args[0]].clone();
get_field(&ty, field, cache)
}
// ──────────────────────────────── Function Calls ──────────────────────────────────
Call | Call0 | Call1 | Call2 | Call3 | TailCall | CallMethod | TailCallMethod => {
let frame = call_frame(instr, &val_types);
match get_field(&frame.callee, FIELD_FN, cache) {
Type::Fn(fn_id) => {
let req = InferRequest {
fn_id,
arg_types: arg_types_for(&reg_fns[fn_id], &frame),
};
match cache.get(&req) {
Some(res) => res.ret_type.clone(),
None => {
let res = walk_fn(&req, reg_fns, cache);
let ret_ty = res.ret_type.clone();
cache.insert(req, res);
ret_ty
}
}
}
// A callee we cannot resolve to one function: a
// builtin, or a value we lost track of.
_ => Type::Any,
}
}
// ────────────────────────────────────── Returns ───────────────────────────────────
Return | ReturnAsync => val_types[instr.args[0]].clone(),
ReturnUndef => Type::Empty,
_ => unreachable!("{instr:?}"),
};
if is_ret {
ret_type = join_ty(&ret_type, &ty);
} else if instr.op == DefineField {
// A define states its result completely, from its operands.
// Joining it with the object's previous type would only
// destroy the chain that a write-back is building up.
for &dst in &instr.dsts {
val_types[dst] = ty.clone();
}
} else {
for &dst in &instr.dsts {
val_types[dst] = join_ty(&val_types[dst], &ty);
}
}
}
}
InferResult {
val_types,
ret_type,
}
}
fn atom_to_str(a: &AtomRef, atoms: &[Atom]) -> String {
match a {
AtomRef::TaggedInt(n) => n.to_string(),
AtomRef::Predefined(i) => atom_names::PREDEFINED_ATOMS
.get(*i as usize)
.copied()
.unwrap_or("<predef?>")
.to_string(),
AtomRef::FileLocal(i) => atoms
.get(*i)
.map(atom_text)
.unwrap_or_else(|| format!("<file_atom {i}>")),
}
}
fn atom_text(a: &Atom) -> String {
match a {
Atom::String(s) | Atom::GlobalSymbol(s) | Atom::Symbol(s) => js_string(s),
Atom::Predefined(n) => format!("<predef {n}>"),
}
}
fn js_string(s: &JsString) -> String {
match s {
JsString::Latin1(b) => b.iter().map(|&c| c as char).collect(),
JsString::UTF16(b) => {
let u: Vec<u16> = b
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&u)
}
}
}
+347
View File
@@ -0,0 +1,347 @@
#![allow(non_snake_case)]
use std::collections::HashMap;
pub mod types;
use types::{Type, get_field, join_ty, set_field};
use crate::{
builtins,
libqjs::parse::{ImmOperand, JsValue, opcodes::OpCode},
model::EXTRA_ARGS,
regify::RegFn,
};
#[derive(Debug, Hash, PartialEq, Eq)]
pub struct InferRequest {
pub fn_id: usize,
pub arg_types: Vec<Type>,
}
#[derive(Debug)]
pub struct InferResult {
pub val_types: Vec<Type>,
pub ret_type: Type,
}
/// Names one object shape. Emit turns each into a struct.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ShapeId(pub usize);
/// What inference accumulated: a result per function-and-argument pair, and a
/// number for every distinct object shape. Shapes are kept exactly as built, so
/// two objects with the same fields in a different order are two shapes.
#[derive(Debug, Default)]
pub struct TypeCache {
results: HashMap<InferRequest, InferResult>,
shapes: HashMap<Type, ShapeId>,
next_shape: usize,
}
impl TypeCache {
pub fn get(&self, req: &InferRequest) -> Option<&InferResult> {
self.results.get(req)
}
pub fn insert(&mut self, req: InferRequest, res: InferResult) {
self.results.insert(req, res);
}
/// Number this shape, reusing the number if it has been seen before.
pub fn shape_of(&mut self, ty: &Type) -> ShapeId {
if let Some(id) = self.shapes.get(ty) {
return *id;
}
let id = ShapeId(self.next_shape);
self.next_shape += 1;
self.shapes.insert(ty.clone(), id);
id
}
/// The number already given to this shape, if it has one.
pub fn shape_id(&self, ty: &Type) -> Option<ShapeId> {
self.shapes.get(ty).copied()
}
pub fn results(&self) -> impl Iterator<Item = (&InferRequest, &InferResult)> {
self.results.iter()
}
pub fn shapes(&self) -> impl Iterator<Item = (&Type, ShapeId)> {
self.shapes.iter().map(|(ty, id)| (ty, *id))
}
}
pub fn infer_types(reg_fns: &[RegFn]) -> TypeCache {
// The synthetic program entry is appended last by `flatten`. It is called
// by the host with nothing, so its own parameters are undefined.
let entry = reg_fns.len() - 1;
let mut cache = TypeCache::default();
let req = InferRequest {
fn_id: entry,
arg_types: vec![Type::Undefined; reg_fns[entry].arg_count],
};
let res = walk_fn(&req, reg_fns, &mut cache);
cache.insert(req, res);
cache
}
fn walk_fn(req: &InferRequest, reg_fns: &[RegFn], cache: &mut TypeCache) -> InferResult {
use OpCode::*;
let f = &reg_fns[req.fn_id];
let mut val_types = vec![Type::Unknown; f.val_count];
let mut ret_type = Type::Unknown;
// Args are not renamed: arg `k` is value id `var_count + k`, and holds what
// the caller passed until the function writes it.
for (k, ty) in req.arg_types.iter().enumerate() {
val_types[f.var_count + k] = ty.clone();
}
// Types only grow, so walk until they stop changing. Then every
// instruction has seen the final type of what it reads, including what a
// later write or a loop back edge adds. For that, every rule must be
// monotone: `Unknown` means nothing has reached a value yet, so a rule
// that needs it gives `Unknown` too, never a guess that it later outgrows.
loop {
let prev = (val_types.clone(), ret_type.clone());
for block in &f.blocks {
let code = &f.code[block.start..block.end];
let code = code.iter().chain(&block.postfix);
'instrs: for instr in code {
let is_ret = matches!(instr.op, Return | ReturnAsync | ReturnUndef);
if instr.dsts.is_empty() && !is_ret {
continue;
}
// TODO: Some of these ops may not every show up by this point
let ty = match instr.op {
// ──────────────────────────────────── Literals ────────────────────────────────────
PushMinus1 | Push0 | Push1 | Push2 | Push3 | Push4 | Push5 | Push6 | Push7
| PushI8 | PushI16 | PushI32 => Type::Int,
PushTrue | PushFalse => Type::Bool,
PushConst | PushConst8 => match instr.imm {
ImmOperand::Const(k) => match f.cpool.get(k as usize) {
Some(JsValue::Int(_)) => Type::Int,
Some(JsValue::Float(_)) => Type::Float,
Some(JsValue::BoolTrue) | Some(JsValue::BoolFalse) => Type::Bool,
Some(JsValue::Native(id)) => Type::Native(*id),
_ => unreachable!(),
},
_ => unreachable!(),
},
// ─────────────────────────────────── Arithmetic ───────────────────────────────────
Add | Sub | Mul | Mod | Pow => {
match (&val_types[instr.args[0]], &val_types[instr.args[1]]) {
(Type::Unknown, _) | (_, Type::Unknown) => Type::Unknown,
(Type::Int, Type::Int) => Type::Int,
(Type::Float, Type::Float)
| (Type::Int, Type::Float)
| (Type::Float, Type::Int) => Type::Float,
_ => Type::Any,
}
}
Neg | Plus | Inc | Dec | PostInc | PostDec => match val_types[instr.args[0]] {
Type::Unknown => Type::Unknown,
Type::Int => Type::Int,
Type::Float => Type::Float,
_ => Type::Any,
},
Div => Type::Float,
Lt | Lte | Gt | Gte | Eq | Neq | StrictEq | StrictNeq | Not => Type::Bool,
// ────────────────────────────────── Value Movers ──────────────────────────────────
SetLoc | SetName => val_types[instr.args[0]].clone(),
Undefined => Type::Undefined,
// ──────────────────────────────────── Objects ─────────────────────────────────────
Object => Type::Empty,
// A closure of the child function, with nothing captured yet.
// The captures that follow are written into its environment.
Fclosure | Fclosure8 => match instr.imm {
ImmOperand::Const(k) => match f.cpool[k as usize] {
JsValue::FnById(fn_id) => Type::Closure {
fn_id,
env: Box::new(Type::Empty),
},
_ => unreachable!(),
},
_ => unreachable!(),
},
DefineField => {
let field = match instr.imm {
ImmOperand::Atom(atom) => atom,
_ => unreachable!(),
};
let obj = &val_types[instr.args[1]];
if *obj == Type::Unknown {
continue;
}
let ty = set_field(obj, field, &val_types[instr.args[0]]);
// Register the shape that will be emitted. A closure's is
// its environment; an empty environment needs no struct.
let shape = match &ty {
Type::Closure { env, .. } => env.as_ref(),
other => other,
};
if matches!(shape, Type::WithField { .. }) {
cache.shape_of(shape);
}
ty
}
GetField => {
let field = match instr.imm {
ImmOperand::Atom(atom) => atom,
_ => unreachable!(),
};
let ty = val_types[instr.args[0]].clone();
get_field(&ty, field)
}
// ──────────────────────────────── Function Calls ──────────────────────────────────
Call | Call0 | Call1 | Call2 | Call3 | TailCall | CallMethod
| TailCallMethod => 'call: {
// args are in pop order: [this,] callee, arg0 .. arg_n. A
// method call carries a receiver beneath the callee; a plain
// call has none, so its `this` is undefined.
let is_method = matches!(instr.op, CallMethod | TailCallMethod);
let n = instr.args.len();
let (callee_idx, this, arg_end) = if is_method {
(
instr.args[n - 2],
val_types[instr.args[n - 1]].clone(),
n - 2,
)
} else {
(instr.args[n - 1], Type::Undefined, n - 1)
};
let fn_id = match val_types[callee_idx] {
Type::Closure { fn_id, .. } => fn_id,
// A C function has no body to walk: its return type
// is declared, from the args in source order.
Type::Native(id) => {
let args: Vec<Type> = instr.args[..arg_end]
.iter()
.rev()
.map(|&v| val_types[v].clone())
.collect();
break 'call (builtins::NATIVES[id].ret)(&args);
}
// Nothing has reached the callee yet, so nothing
// reaches the result either.
Type::Unknown => continue 'instrs,
_ => unreachable!(),
};
// The callee sees its own params, then this, then the
// environment. The actuals are padded or truncated to the
// declared arity, so the last two land at a fixed index
// whatever the caller passed.
let callee = &reg_fns[fn_id];
let declared = callee.arg_count - EXTRA_ARGS;
let mut arg_types = Vec::with_capacity(callee.arg_count);
// The value each param was passed from, if any.
let mut operands = Vec::with_capacity(callee.arg_count);
for i in 0..declared {
let v = (i < arg_end).then(|| instr.args[arg_end - 1 - i]);
arg_types.push(v.map_or(Type::Undefined, |v| val_types[v].clone()));
operands.push(v);
}
arg_types.push(this);
operands.push(is_method.then(|| instr.args[n - 1]));
arg_types.push(val_types[callee_idx].clone());
operands.push(Some(callee_idx));
let req = InferRequest { fn_id, arg_types };
// What the call returns, and what its params hold at the
// end: the callee's writes to them.
let params = callee.var_count..callee.var_count + callee.arg_count;
let (ret_ty, outs) = match cache.get(&req) {
Some(res) => (res.ret_type.clone(), res.val_types[params].to_vec()),
None => {
let res = walk_fn(&req, reg_fns, cache);
let summary = (res.ret_type.clone(), res.val_types[params].to_vec());
cache.insert(req, res);
summary
}
};
// The caller passed these values, so it sees the callee's
// writes to them.
for (v, out) in operands.into_iter().zip(&outs) {
if let Some(v) = v {
val_types[v] = join_ty(&val_types[v], out);
}
}
ret_ty
}
// ────────────────────────────────────── Returns ───────────────────────────────────
Return | ReturnAsync => val_types[instr.args[0]].clone(),
ReturnUndef => Type::Undefined,
_ => unreachable!("{instr:?}"),
};
if is_ret {
ret_type = join_ty(&ret_type, &ty);
} else {
for &dst in &instr.dsts {
val_types[dst] = join_ty(&val_types[dst], &ty);
}
}
// A copy of an object is the same object, so what is done
// through the copy is done to the original as well.
if instr.op == SetLoc && is_object(&val_types[instr.dsts[0]]) {
let (src, dst) = (instr.args[0], instr.dsts[0]);
val_types[src] = join_ty(&val_types[src], &val_types[dst]);
}
}
}
if (&val_types, &ret_type) == (&prev.0, &prev.1) {
break;
}
}
InferResult {
val_types,
ret_type,
}
}
fn is_object(ty: &Type) -> bool {
matches!(ty, Type::Empty | Type::WithField { .. } | Type::Closure { .. })
}
+229
View File
@@ -0,0 +1,229 @@
//! The type lattice, and how an object type is read and written.
//!
//! An object is a chain of `WithField` links. Everything that reads or writes
//! one goes through this file: `Fields` walks it, `get_field` looks one field
//! up, `set_field` writes one.
use crate::libqjs::parse::AtomRef;
use crate::model::{FIELD_PROTO, MAX_PROTO_DEPTH};
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum Type {
Unknown,
Undefined,
Bool,
Int,
Float,
Empty,
/// A closure: a function together with its captured environment. `fn_id`
/// is set by the `fclosure` that creates it and selects the code; it is
/// compile-time only, never a field, never emitted. `env` is the record of
/// captures, written into as ordinary fields, and that is what becomes a
/// struct. Two closures over the same captures share one env shape.
Closure {
fn_id: usize,
env: Box<Type>,
},
/// A C function, by its index in `builtins::NATIVES`. Only a builtin's
/// body reads one, and only to call it.
Native(usize),
/// An object with field `name` of type `value`, over the object `rest`.
/// A chain of these is how a field bag builds up.
WithField {
name: AtomRef,
value: Box<Type>,
rest: Box<Type>,
},
Any,
}
/// Stood in for a chain that ran deeper than [`MAX_PROTO_DEPTH`]. Pointing the
/// cursor here makes [`Fields::tail`] answer `Any`, so an over-deep chain gives
/// up conservatively instead of reporting a field as absent.
static TOO_DEEP: Type = Type::Any;
/// Walks the fields of an object type.
///
/// In prototype mode the own fields come first and the prototype's after, so a
/// name the object defines itself shadows the prototype's simply by arriving
/// first. That also means a name can be yielded more than once in this mode:
/// the first occurrence is the one that counts. Own mode yields each name once.
pub struct Fields<'a> {
current: &'a Type,
proto: Option<&'a Type>,
follow: bool,
depth: usize,
}
impl<'a> Fields<'a> {
/// What the chain ended on: `Empty` for an object whose fields are fully
/// known, `Any` when the shape is not, anything else for a non-object.
pub fn tail(&self) -> &'a Type {
self.current
}
}
impl<'a> Iterator for Fields<'a> {
type Item = (AtomRef, &'a Type);
fn next(&mut self) -> Option<Self::Item> {
loop {
match self.current {
Type::WithField { name, value, rest } => {
if *name == FIELD_PROTO && self.follow {
self.proto = Some(value);
}
self.current = rest;
return Some((*name, value));
}
// A closure's fields are its environment's. The function itself
// is compile-time only, so it is never enumerated.
Type::Closure { env, .. } => self.current = env,
// Own fields are exhausted. Step out to the prototype, if we
// follow them and there is one left to step to.
_ => match self.proto.take() {
Some(proto) if self.depth < MAX_PROTO_DEPTH => {
self.depth += 1;
self.current = proto;
}
Some(_) => {
self.current = &TOO_DEEP;
return None;
}
None => return None,
},
}
}
}
}
impl Type {
/// The object's own fields, not those of its prototype.
pub fn fields(&self) -> Fields<'_> {
Fields {
current: self,
proto: None,
follow: false,
depth: 0,
}
}
/// The object's own fields, then its prototype's, and so on outward.
pub fn fields_through_proto(&self) -> Fields<'_> {
Fields {
current: self,
proto: None,
follow: true,
depth: 0,
}
}
}
/// Write `field` onto `obj`. A field the object already has is joined with the
/// new value where it sits, so a name appears at most once and a chain is
/// bounded by the number of distinct fields rather than the number of writes.
/// Writing a type the field already covers leaves the object alone.
pub fn set_field(obj: &Type, field: AtomRef, value: &Type) -> Type {
match obj {
// A field written onto a closure goes into its environment.
Type::Closure { fn_id, env } => Type::Closure {
fn_id: *fn_id,
env: Box::new(set_field(env, field, value)),
},
Type::WithField {
name,
value: v,
rest,
} if *name == field => {
let joined = join_ty(v, value);
if joined == **v {
return obj.clone();
}
Type::WithField {
name: field,
value: Box::new(joined),
rest: rest.clone(),
}
}
Type::WithField {
name,
value: v,
rest,
} => {
let new_rest = set_field(rest, field, value);
// Nothing changed deeper down, so nothing changes here either.
if new_rest == **rest {
return obj.clone();
}
Type::WithField {
name: *name,
value: v.clone(),
rest: Box::new(new_rest),
}
}
_ => Type::WithField {
name: field,
value: Box::new(value.clone()),
rest: Box::new(obj.clone()),
},
}
}
/// Look `field` up on `ty`, own fields first and the prototype's after, which
/// is how a method defined on a prototype is found from an instance. A field
/// no write has reached is `Unknown`, not `Undefined`: inference is still
/// growing the object, and a later write may add it. Emit reads it as
/// undefined.
pub fn get_field(ty: &Type, field: AtomRef) -> Type {
let mut it = ty.fields_through_proto();
let found = it.find(|(name, _)| *name == field);
match found {
Some((_, value)) => value.clone(),
// Nothing named it, and the shape was not fully known, so it might
// still have been there.
None if matches!(it.tail(), Type::Any) => Type::Any,
None => Type::Unknown,
}
}
/// Type lattice join rules:
/// Unknown * T = T
/// Any * T = Any
/// T * T = T
/// Closure(f, e) * Closure(f, e') = Closure(f, e * e')
/// object A * object B = every field of A and B, each field joined
/// T * U = Any
///
/// Objects join field by field, so an object that gains a field only grows,
/// and a field written with two different types becomes `Any`.
pub fn join_ty(a: &Type, b: &Type) -> Type {
match (a, b) {
(Type::Unknown, t) | (t, Type::Unknown) => t.clone(),
(Type::Any, _) | (_, Type::Any) => Type::Any,
(a, b) if a == b => a.clone(),
(Type::Closure { fn_id: f, env: e }, Type::Closure { fn_id: g, env: e2 }) if f == g => {
Type::Closure {
fn_id: *f,
env: Box::new(join_ty(e, e2)),
}
}
(Type::Empty | Type::WithField { .. }, Type::Empty | Type::WithField { .. }) => b
.fields()
.fold(a.clone(), |acc, (name, value)| set_field(&acc, name, value)),
_ => Type::Any,
}
}
+4 -1
View File
@@ -12,9 +12,12 @@ pub fn compile(src: &[u8], filename: &str) -> QuickJSBinary {
let rt = ffi::JS_NewRuntime();
let ctx = ffi::JS_NewContext(rt);
// JS_Eval requires a '\0' after the last byte of the source.
let src_z = [src, &[0]].concat();
let obj = ffi::JS_Eval(
ctx,
src.as_ptr(),
src_z.as_ptr().cast(),
src.len(),
filename.as_ptr().cast(),
ffi::JS_EVAL_FLAG_COMPILE_ONLY | ffi::JS_EVAL_TYPE_MODULE,
+39
View File
@@ -168,6 +168,9 @@ pub enum JsValue {
},
Function(Function),
FnById(usize),
/// A C function, by its index in `builtins::NATIVES`. Transform puts it in
/// place of the global a builtin calls it by.
Native(usize),
Module(Module),
TypedArray,
ArrayBuffer,
@@ -894,3 +897,39 @@ impl Debug for JsString {
f.write_char('"')
}
}
/// Resolve an atom reference to its text, for names and diagnostics.
pub fn atom_to_str(a: &AtomRef, atoms: &[Atom]) -> String {
match a {
AtomRef::TaggedInt(n) => n.to_string(),
AtomRef::Predefined(i) => atom_names::PREDEFINED_ATOMS
.get(*i as usize)
.copied()
.unwrap_or("<predef?>")
.to_string(),
AtomRef::FileLocal(i) => atoms
.get(*i)
.map(atom_text)
.unwrap_or_else(|| format!("<file_atom {i}>")),
}
}
pub fn atom_text(a: &Atom) -> String {
match a {
Atom::String(s) | Atom::GlobalSymbol(s) | Atom::Symbol(s) => js_string(s),
Atom::Predefined(n) => format!("<predef {n}>"),
}
}
pub fn js_string(s: &JsString) -> String {
match s {
JsString::Latin1(b) => b.iter().map(|&c| c as char).collect(),
JsString::UTF16(b) => {
let u: Vec<u16> = b
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&u)
}
}
}
-4
View File
@@ -7,10 +7,6 @@
use crate::libqjs::parse::AtomRef;
/// The function held by a closure object. Every closure carries one, and a
/// call reads it to find the function behind the object it is calling.
pub const FIELD_FN: AtomRef = AtomRef::Predefined(0); // "<null>"
/// An instance's prototype object. A field lookup that misses on an object
/// itself follows this link and retries.
pub const FIELD_PROTO: AtomRef = AtomRef::Predefined(124); // "<home_object>"
+85 -22
View File
@@ -41,6 +41,9 @@ impl std::fmt::Debug for RegInstr {
pub struct RegFn {
pub name: AtomRef,
pub arg_count: usize,
/// Locals and args are not renamed: local `k` is value id `k`, and arg `k`
/// is value id `var_count + k`.
pub var_count: usize,
pub val_count: usize,
pub cpool: Vec<JsValue>,
pub code: Vec<RegInstr>,
@@ -52,7 +55,8 @@ impl RegFn {
let mut blocks = find_basic_blocks(&flat_fn.code);
let mut out = vec![RegInstr::nop(); flat_fn.code.len()];
let mut next_val = flat_fn.var_count; // Reserve values for locals
// Reserve values for locals, then args
let mut next_val = flat_fn.var_count + flat_fn.arg_count;
let mut entry_stacks: Vec<Option<Vec<usize>>> = vec![None; blocks.len()];
let mut worklist = vec![];
@@ -64,7 +68,7 @@ impl RegFn {
let block = &mut blocks[block_idx];
let mut stack: Vec<usize> = entry_stacks[block_idx].clone().unwrap_or_default();
walk_block(block, &flat_fn.code, &mut stack, &mut next_val, &mut out);
walk_block(block, &flat_fn.code, flat_fn.var_count, &mut stack, &mut next_val, &mut out);
for succ_idx in block.succ.clone() {
let succ_stack = &mut entry_stacks[succ_idx];
@@ -82,6 +86,7 @@ impl RegFn {
Self {
name: flat_fn.name,
arg_count: flat_fn.arg_count,
var_count: flat_fn.var_count,
val_count: next_val,
cpool: flat_fn.cpool,
code: out,
@@ -93,6 +98,7 @@ impl RegFn {
fn walk_block(
block: &BasicBlock,
code: &[Instr],
var_count: usize,
stack: &mut Vec<usize>,
next_val: &mut usize,
out: &mut [RegInstr],
@@ -123,39 +129,37 @@ fn walk_block(
out_instr.args.push(v);
}
// Locals are not renamed, local read/writes need special handling
// Handle read of a local value
match is_local_read(instr) {
Some(loc) => {
stack.push(loc);
out_instr.op = OpCode::Nop;
out_instr.imm = ImmOperand::None;
out[instr_idx] = out_instr;
continue;
}
None if instr.op == OpCode::GetLoc0Loc1 => {
stack.push(0);
stack.push(1);
out_instr.op = OpCode::Nop;
// Locals and args are not renamed, their read/writes need special handling
// Handle read of a local or arg value
match is_var_read(instr, var_count) {
// A read takes the var's value at this point. The stack holds a
// copy, never the var itself.
Some(var) => {
let v = fresh_val();
stack.push(v);
out_instr.op = OpCode::SetLoc;
out_instr.imm = ImmOperand::None;
out_instr.dsts.push(v);
out_instr.args.push(var);
out[instr_idx] = out_instr;
continue;
}
None => (),
}
// Handle write of a local value
match is_local_write(instr) {
// Handle write of a local or arg value
match is_var_write(instr, var_count) {
Some(loc) => {
out_instr.op = OpCode::SetLoc;
out_instr.imm = ImmOperand::None;
out_instr.dsts.push(loc);
out[instr_idx] = out_instr;
// set_loc also leaves the value it stored on the stack.
if n_push == 1 {
stack.push(loc);
stack.push(out_instr.args[0]);
}
out[instr_idx] = out_instr;
continue;
}
None if instr.op == OpCode::SetLocUninitialized => {
@@ -167,6 +171,39 @@ fn walk_block(
None => (),
}
// set_home_object binds [[HomeObject]], which only `super` reads, and
// super is not supported. It returns both its operands unchanged, so on
// values it is nothing at all: hand them straight back.
if instr.op == OpCode::SetHomeObject {
stack.push(out_instr.args[1]);
stack.push(out_instr.args[0]);
out_instr.op = OpCode::Nop;
out_instr.imm = ImmOperand::None;
out[instr_idx] = out_instr;
continue;
}
// dup only copies a stack slot; it computes nothing. The copy is the
// same value, so whatever is done through it is done to the original.
if instr.op == OpCode::Dup {
stack.push(out_instr.args[0]);
stack.push(out_instr.args[0]);
out_instr.op = OpCode::Nop;
out_instr.imm = ImmOperand::None;
out[instr_idx] = out_instr;
continue;
}
// swap only reorders the stack; it computes nothing.
if instr.op == OpCode::Swap {
stack.push(out_instr.args[0]);
stack.push(out_instr.args[1]);
out_instr.op = OpCode::Nop;
out_instr.imm = ImmOperand::None;
out[instr_idx] = out_instr;
continue;
}
// A field write is a define whose result lands back in the object's own
// value, so later reads of that object see the field. This is what lets
// a chain of writes to one local build a single object type, and it is
@@ -203,7 +240,10 @@ fn extra_pops(instr: &Instr) -> usize {
}
}
fn is_local_read(instr: &Instr) -> Option<usize> {
/// The value id of the local or arg that `instr` reads, if any.
fn is_var_read(instr: &Instr, var_count: usize) -> Option<usize> {
let arg = |k: usize| Some(var_count + k);
match instr.op {
OpCode::GetLoc0 => Some(0),
OpCode::GetLoc1 => Some(1),
@@ -215,11 +255,24 @@ fn is_local_read(instr: &Instr) -> Option<usize> {
_ => unreachable!(),
},
OpCode::GetArg0 => arg(0),
OpCode::GetArg1 => arg(1),
OpCode::GetArg2 => arg(2),
OpCode::GetArg3 => arg(3),
OpCode::GetArg => match instr.imm {
ImmOperand::Arg(k) => arg(k as usize),
_ => unreachable!(),
},
_ => None,
}
}
fn is_local_write(instr: &Instr) -> Option<usize> {
/// The value id of the local or arg that `instr` writes, if any.
fn is_var_write(instr: &Instr, var_count: usize) -> Option<usize> {
let arg = |k: usize| Some(var_count + k);
match instr.op {
OpCode::PutLoc0 | OpCode::SetLoc0 => Some(0),
OpCode::PutLoc1 | OpCode::SetLoc1 => Some(1),
@@ -236,6 +289,16 @@ fn is_local_write(instr: &Instr) -> Option<usize> {
_ => unreachable!(),
},
OpCode::PutArg0 | OpCode::SetArg0 => arg(0),
OpCode::PutArg1 | OpCode::SetArg1 => arg(1),
OpCode::PutArg2 | OpCode::SetArg2 => arg(2),
OpCode::PutArg3 | OpCode::SetArg3 => arg(3),
OpCode::PutArg | OpCode::SetArg => match instr.imm {
ImmOperand::Arg(k) => arg(k as usize),
_ => unreachable!(),
},
_ => None,
}
}
+16 -5
View File
@@ -21,12 +21,23 @@ impl ClassSequence {
imm: ImmOperand::Const(self.ctor_fn_cpool_idx),
});
// define_class left the constructor with its prototype above it, and the
// code after the class body stores that constructor into the class
// variable. get_field consumes what it reads, so duplicate first.
out.push(Instr {
pc: self.start_pc,
op: OpCode::Dup,
imm: ImmOperand::None,
});
out.push(Instr {
pc: self.start_pc,
op: OpCode::GetField,
imm: ImmOperand::Atom(AtomRef::Predefined(63)), // .prototype
});
// define_field hands the prototype back, so every method defines onto
// that one object and the lookup is not repeated.
for m in &self.methods {
out.push(Instr {
pc: self.start_pc,
op: OpCode::GetField,
imm: ImmOperand::Atom(AtomRef::Predefined(63)), // .prototype
});
out.push(Instr {
pc: self.start_pc,
op: OpCode::Fclosure8,
+169 -47
View File
@@ -1,22 +1,34 @@
mod declass;
use crate::{
builtins,
flatten::FlatFn,
libqjs::parse::{ClosureVar, ImmOperand, Instr, JsValue, opcodes::OpCode},
model::{EXTRA_ARGS, FIELD_FN},
libqjs::parse::{
Atom, ClosureKind, ClosureVar, ImmOperand, Instr, JsValue, atom_to_str, opcodes::OpCode,
},
model::{EXTRA_ARGS, FIELD_PROTO},
};
use declass::declass;
pub fn transform(flat_fns: Vec<FlatFn>, captures: &[Vec<ClosureVar>]) -> Vec<FlatFn> {
pub fn transform(
flat_fns: Vec<FlatFn>,
captures: &[Vec<ClosureVar>],
atoms: &[Atom],
) -> Vec<FlatFn> {
flat_fns
.into_iter()
.enumerate()
.map(|(i, f)| transform_one(i, f, captures))
.map(|(i, f)| transform_one(i, f, captures, atoms))
.collect()
}
fn transform_one(fn_id: usize, f: FlatFn, captures: &[Vec<ClosureVar>]) -> FlatFn {
fn transform_one(
fn_id: usize,
mut f: FlatFn,
captures: &[Vec<ClosureVar>],
atoms: &[Atom],
) -> FlatFn {
let declassed = declass(&f.code);
// Every function gains two parameters after its own, in this order. The
@@ -28,22 +40,34 @@ fn transform_one(fn_id: usize, f: FlatFn, captures: &[Vec<ClosureVar>]) -> FlatF
for instr in &declassed {
if let Some(info) = is_fclosure(instr, &f, captures) {
out_len += 1 + (2 * info.child_captures.len()) + 2; // object; (push; define_field)*; push_fn_const; define_field
// fclosure; (load; define_field)*
out_len += 1 + info.child_captures.iter().map(capture_len).sum::<usize>();
continue;
}
if is_capture_acc(instr).is_some() || is_tail_call(instr) {
// get_arg env; swap; put_field
if let Some(CaptureAccess::Write(_)) = is_capture_acc(instr) {
out_len += 3;
continue;
}
if is_capture_acc(instr).is_some() {
out_len += 2;
continue;
}
if let Some(seq) = expansion(instr.op) {
out_len += seq.len();
continue;
}
out_len += 1;
}
let mut out = Vec::with_capacity(out_len);
for instr in &declassed {
if replace_fclosure(instr, &f, captures, &mut out) {
if replace_fclosure(instr, &f, captures, &captures[fn_id], env_idx, &mut out) {
continue;
}
@@ -55,11 +79,15 @@ fn transform_one(fn_id: usize, f: FlatFn, captures: &[Vec<ClosureVar>]) -> FlatF
continue;
}
if replace_tail_call(instr, &mut out) {
if replace_expansion(instr, &mut out) {
continue;
}
out.push(instr.clone());
if replace_get_var(instr, atoms, &mut f.cpool, &mut out) {
continue;
}
out.push(renamed(instr).unwrap_or_else(|| instr.clone()));
}
debug_assert!(out.len() == out_len);
@@ -89,7 +117,6 @@ fn replace_push_this(instr: &Instr, this_idx: u16, out: &mut Vec<Instr>) -> bool
}
struct FClosureInfo<'a> {
cpool_idx: u32,
child_captures: &'a [ClosureVar],
}
@@ -98,7 +125,7 @@ fn is_fclosure<'a>(
f: &FlatFn,
captures: &'a [Vec<ClosureVar>],
) -> Option<FClosureInfo<'a>> {
if instr.op != OpCode::Fclosure8 {
if !matches!(instr.op, OpCode::Fclosure8 | OpCode::Fclosure) {
return None;
}
@@ -113,7 +140,6 @@ fn is_fclosure<'a>(
};
Some(FClosureInfo {
cpool_idx,
child_captures: &captures[child_fn_id],
})
}
@@ -122,6 +148,8 @@ fn replace_fclosure(
instr: &Instr,
f: &FlatFn,
captures: &[Vec<ClosureVar>],
my_captures: &[ClosureVar],
env_idx: u16,
out: &mut Vec<Instr>,
) -> bool {
let info = match is_fclosure(instr, f, captures) {
@@ -129,43 +157,66 @@ fn replace_fclosure(
None => return false,
};
let pc = instr.pc;
out.push(Instr {
pc,
op: OpCode::Object,
imm: ImmOperand::None,
});
// The closure keeps the op that makes it: `fclosure` creates a closure of the
// child with an empty environment, and each capture is written into that
// environment as an ordinary field.
out.push(instr.clone());
for c in info.child_captures {
out.push(Instr {
pc,
op: OpCode::GetLoc,
imm: ImmOperand::Loc(c.var_idx as u16),
});
if !load_capture(c, my_captures, env_idx, instr.pc, out) {
continue;
}
out.push(Instr {
pc,
pc: instr.pc,
op: OpCode::DefineField,
imm: ImmOperand::Atom(c.name),
});
}
out.push(Instr {
pc,
op: OpCode::PushConst,
imm: ImmOperand::Const(info.cpool_idx),
});
true
}
out.push(Instr {
pc,
op: OpCode::DefineField,
imm: ImmOperand::Atom(FIELD_FN),
});
/// Load the value a child closure captures, from where it lives in this frame.
/// `var_idx` means something different per kind: a local slot, an argument, or
/// one of this function's own captures — which, after lowering, is a field of
/// its env. A module binding has no frame at all, so nothing is loaded and the
/// capture is not written: its field first appears when `<eval>` writes it,
/// and a read before that finds no field and gives undefined.
fn load_capture(
c: &ClosureVar,
my_captures: &[ClosureVar],
env_idx: u16,
pc: usize,
out: &mut Vec<Instr>,
) -> bool {
let mut push = |op, imm| out.push(Instr { pc, op, imm });
match c.flags.closure_kind {
ClosureKind::Local => push(OpCode::GetLoc, ImmOperand::Loc(c.var_idx as u16)),
ClosureKind::Arg => push(OpCode::GetArg, ImmOperand::Arg(c.var_idx as u16)),
ClosureKind::Ref => {
push(OpCode::GetArg, ImmOperand::Arg(env_idx));
push(
OpCode::GetField,
ImmOperand::Atom(my_captures[c.var_idx].name),
);
}
_ => return false,
}
true
}
/// How many instructions one capture takes: its load, then its define_field.
fn capture_len(c: &ClosureVar) -> usize {
match c.flags.closure_kind {
ClosureKind::Local | ClosureKind::Arg => 2,
ClosureKind::Ref => 3,
_ => 0,
}
}
enum CaptureAccess {
Read(u16),
Write(u16),
@@ -232,12 +283,20 @@ fn replace_capture_accs(
CaptureAccess::Write(write_var) => {
let atom = my_captures[write_var as usize].name;
// The value is already on the stack, so env lands above it, and
// put_field wants the object below the value.
out.push(Instr {
pc,
op: OpCode::GetArg,
imm: ImmOperand::Arg(env_idx),
});
out.push(Instr {
pc,
op: OpCode::Swap,
imm: ImmOperand::None,
});
out.push(Instr {
pc,
op: OpCode::PutField,
@@ -249,26 +308,89 @@ fn replace_capture_accs(
true
}
fn is_tail_call(instr: &Instr) -> bool {
instr.op == OpCode::TailCall
/// Ops that are a fixed sequence of simpler ones. `true` passes the original
/// immediate on to that op.
fn expansion(op: OpCode) -> Option<&'static [(OpCode, bool)]> {
use OpCode::*;
let seq: &'static [(OpCode, bool)] = match op {
TailCall => &[(Call, true), (Return, false)],
TailCallMethod => &[(CallMethod, true), (Return, false)],
GetField2 => &[(Dup, false), (GetField, true)],
GetLoc0Loc1 => &[(GetLoc0, false), (GetLoc1, false)],
IncLoc => &[(GetLoc, true), (Inc, false), (PutLoc, true)],
DecLoc => &[(GetLoc, true), (Dec, false), (PutLoc, true)],
// The added value is on the stack; the local is the left operand.
AddLoc => &[(GetLoc, true), (Swap, false), (Add, false), (PutLoc, true)],
_ => return None,
};
Some(seq)
}
fn replace_tail_call(instr: &Instr, out: &mut Vec<Instr>) -> bool {
if !is_tail_call(instr) {
fn replace_expansion(instr: &Instr, out: &mut Vec<Instr>) -> bool {
let Some(seq) = expansion(instr.op) else {
return false;
};
for &(op, keep_imm) in seq {
let imm = if keep_imm { instr.imm } else { ImmOperand::None };
out.push(Instr { pc: instr.pc, op, imm });
}
true
}
/// The only globals a program reads are the C functions builtins call. Each
/// becomes a constant naming it.
fn replace_get_var(
instr: &Instr,
atoms: &[Atom],
cpool: &mut Vec<JsValue>,
out: &mut Vec<Instr>,
) -> bool {
let ImmOperand::Atom(atom) = instr.imm else {
return false;
};
if instr.op != OpCode::GetVar {
return false;
}
out.push(Instr {
pc: instr.pc,
op: OpCode::Call,
imm: instr.imm,
});
let name = atom_to_str(&atom, atoms);
let native = builtins::native(&name).unwrap_or_else(|| panic!("unknown global `{name}`"));
cpool.push(JsValue::Native(native));
out.push(Instr {
pc: instr.pc,
op: OpCode::Return,
imm: ImmOperand::None,
op: OpCode::PushConst,
imm: ImmOperand::Const(cpool.len() as u32 - 1),
});
true
}
/// Ops that are another op with a different immediate.
fn renamed(instr: &Instr) -> Option<Instr> {
let define_field = |atom| Instr {
pc: instr.pc,
op: OpCode::DefineField,
imm: ImmOperand::Atom(atom),
};
match (instr.op, instr.imm) {
// `{ __proto__: p }`: the prototype is an ordinary field.
(OpCode::SetProto, _) => Some(define_field(FIELD_PROTO)),
// A method in an object literal is a field holding a closure. The low
// two bits of `arg` say method, getter or setter; only methods are
// supported.
(OpCode::DefineMethod, ImmOperand::AtomU8 { atom, arg }) => {
assert!(arg & 3 == 0, "getters and setters are not supported");
Some(define_field(atom))
}
_ => None,
}
}
+7
View File
@@ -0,0 +1,7 @@
const Object = {
create(proto) { return { __proto__: proto }; },
};
const console = {
log(x) { __c_print(x); },
};