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
+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
}