diff --git a/crates/oxide_compiler/src/compiler.rs b/crates/oxide_compiler/src/compiler.rs index 08ff3ac..5bd699c 100644 --- a/crates/oxide_compiler/src/compiler.rs +++ b/crates/oxide_compiler/src/compiler.rs @@ -757,6 +757,10 @@ impl Compiler { } ctx.max_regs = ctx.max_regs.max(1); ctx.reg_overflow = false; + // Count-pass instruction total for this body, compared against the emit pass below + // to catch any counter/emitter drift (which would mis-target jumps). reset_regs zeroes + // projected_pc, so capture it first. + let counted_pc = ctx.projected_pc; ctx.reset_regs(); // Emit pass - reallocate params (same order = same regs after reset) @@ -794,6 +798,14 @@ impl Compiler { } } + debug_assert_eq!( + counted_pc, + ctx.bytecode.len(), + "counter/emitter instruction drift in function body (before implicit RETURN): counted {} vs emitted {}", + counted_pc, + ctx.bytecode.len() + ); + // Emit implicit RETURN: expression body returns the last expression, // statement body returns undefined. if is_expression_body { @@ -860,6 +872,9 @@ impl Compiler { crate::compiler_debug!("counter: {} instructions estimated", ctx.projected_pc); ctx.max_regs = ctx.max_regs.max(1); ctx.reg_overflow = false; + // Count-pass instruction total, compared against the emit pass below to catch + // counter/emitter drift. reset_regs zeroes projected_pc, so capture it first. + let counted_pc = ctx.projected_pc; ctx.reset_regs(); // First sub-pass: emit FunctionDeclarations (hoisting) @@ -883,6 +898,14 @@ impl Compiler { } crate::compiler_debug!("emitter: {} bytes emitted", ctx.bytecode.len()); + debug_assert_eq!( + counted_pc, + ctx.bytecode.len(), + "counter/emitter instruction drift in top-level module (before result store + HALT): counted {} vs emitted {}", + counted_pc, + ctx.bytecode.len() + ); + if let Some(r) = last_result { ctx.emit(opcode::encode(OpCode::LOAD_VAR, 0, r, 0)); } else { diff --git a/crates/oxide_compiler/src/counter/expr/assignment.rs b/crates/oxide_compiler/src/counter/expr/assignment.rs index 675a876..2ee9db1 100644 --- a/crates/oxide_compiler/src/counter/expr/assignment.rs +++ b/crates/oxide_compiler/src/counter/expr/assignment.rs @@ -13,10 +13,8 @@ impl Compiler { } self.count_expression(&member.object, ctx); self.count_expression(&assign.right, ctx); - if assign.operator != AssignmentOperator::Assign { - ctx.alloc_reg(); - ctx.projected_pc += 1; - } + // Both plain (IC_SET_PROP) and compound (COMPOUND_MEMBER_*) emit one opcode + 3 ext + // words after the key load — no extra instruction for the compound case. ctx.count_load_const(); // key ctx.count_ic_set_with_ext(); // IC_SET_PROP or COMPOUND_MEMBER_* + 3 ext words } else if let oxide_parser::AssignmentTarget::ComputedMemberExpression(member) = &assign.left { diff --git a/crates/oxide_compiler/src/counter/expr/control.rs b/crates/oxide_compiler/src/counter/expr/control.rs index 9abedd5..a77cb5a 100644 --- a/crates/oxide_compiler/src/counter/expr/control.rs +++ b/crates/oxide_compiler/src/counter/expr/control.rs @@ -42,21 +42,27 @@ impl Compiler { self.count_expression(&log.right, ctx); ctx.alloc_reg(); ctx.projected_pc += 1; // AND/OR + } else if matches!(log.operator, LogicalOperator::Coalesce) { + // Mirror emit_logical_expression's Coalesce path: it patches raw bytecode + // positions and does NOT consume a label id. Consuming one here would shift + // every later construct's label id and break resolve_label at emit time. + ctx.alloc_reg(); // dup register + ctx.projected_pc += 1; // LOAD_VAR (DUP) + ctx.projected_pc += 1; // JMP_IF_NULLISH + ctx.projected_pc += 1; // JMP over RHS on non-nullish + self.count_expression(&log.right, ctx); + ctx.projected_pc += 1; // LOAD_VAR (overwrite) } else { let id = ctx.next_label_id(); ctx.alloc_reg(); // dup register ctx.projected_pc += 1; // LOAD_VAR (DUP) - ctx.projected_pc += 1; // JMP_IF_FALSE, JMP_IF_TRUE, or JMP_IF_NULLISH - if matches!(log.operator, LogicalOperator::Coalesce) { - ctx.projected_pc += 1; // JMP over RHS on non-nullish - ctx.labels.label_map.insert(Label::TernaryElse(id), ctx.projected_pc); - } + ctx.projected_pc += 1; // JMP_IF_FALSE or JMP_IF_TRUE self.count_expression(&log.right, ctx); ctx.projected_pc += 1; // LOAD_VAR (overwrite) let skip_label = match log.operator { LogicalOperator::And => Label::TernaryEnd(id), LogicalOperator::Or => Label::TernaryElse(id), - LogicalOperator::Coalesce => Label::TernaryEnd(id), + LogicalOperator::Coalesce => unreachable!("coalesce handled above"), }; ctx.labels.label_map.insert(skip_label, ctx.projected_pc); } diff --git a/crates/oxide_vm/tests/compiler_alignment_expr_tests.rs b/crates/oxide_vm/tests/compiler_alignment_expr_tests.rs new file mode 100644 index 0000000..5d8a3e8 --- /dev/null +++ b/crates/oxide_vm/tests/compiler_alignment_expr_tests.rs @@ -0,0 +1,90 @@ +use oxide_compiler::compiler::Compiler; +use oxide_parser::Allocator; +use oxide_vm::vm::Vm; + +fn eval(source: &str) -> String { + let allocator = Allocator::default(); + let program = match oxide_parser::parse(&allocator, source) { + Ok(p) => p, + Err(e) => return format!("parse error: {}", e[0].message), + }; + let module = match Compiler::new().compile(&program) { + Ok(m) => m, + Err(e) => return format!("compile error: {e}"), + }; + let mut vm = Vm::new(); + match vm.run(&module) { + Ok(result) => format!("{result}"), + Err(e) => format!("vm error: {e}"), + } +} + +// Non-simple `??` (side-effecting operands) followed by a label-bearing construct must not +// shift the downstream label id (which produced `Label ... not found` at emit time). + +#[test] +fn coalesce_nonsimple_then_if() { + assert_eq!( + eval("function a(){return null;} function b(){return 2;} var v=a()??b(); if(v>0){v=v+10;} v"), + "12" + ); +} + +#[test] +fn coalesce_nonsimple_then_while() { + assert_eq!( + eval("function a(){return null;} function b(){return 3;} var v=a()??b(); var c=0; while(c0){} v"), "2"); +} + +#[test] +fn compound_member_value_regression() { + assert_eq!(eval("var o={x:10}; o.x-=3; o.x"), "7"); +}