diff --git a/src/Grob.Core/Chunk.cs b/src/Grob.Core/Chunk.cs index fcf8cdc..90fba28 100644 --- a/src/Grob.Core/Chunk.cs +++ b/src/Grob.Core/Chunk.cs @@ -2,15 +2,19 @@ namespace Grob.Core; /// /// A compiled bytecode chunk: instruction bytes, constant pool, and per-instruction -/// source line numbers. +/// source positions (line + column). /// /// Write surface (WriteByte, AddConstant) is used by the compiler /// (Sprint 2 Increment D) and by hand-constructed test chunks (Increment A/B). +/// Source columns are 1-based; 0 is the sentinel used when a chunk byte +/// has no meaningful column origin (synthetic prologue, hand-built test bytecode +/// that did not supply one, etc.). /// public sealed class Chunk { private readonly List _code = []; private readonly List _constants = []; - private readonly List _lines = []; // parallel to _code: source line per byte + private readonly List _lines = []; // parallel to _code: source line per byte + private readonly List _columns = []; // parallel to _code: source column per byte (0 = unknown) // ----- Read surface (Disassembler and VM) ----- @@ -29,17 +33,51 @@ public sealed class Chunk { /// Source line number for the instruction byte at . public int GetLine(int offset) => _lines[offset]; + /// + /// Source column number for the instruction byte at . + /// Returns 0 when no column was supplied at write time. Columns are + /// 1-based when present. + /// + public int GetColumn(int offset) => _columns[offset]; + // ----- Write surface (Compiler / hand-constructed tests) ----- - /// Append a raw byte attributed to . - public void WriteByte(byte value, int line) { + /// + /// Append a raw byte attributed to . Column is + /// recorded as 0 ("unknown"); prefer the + /// overload from the compiler so runtime errors can point at the exact + /// column. + /// + public void WriteByte(byte value, int line) => WriteByte(value, line, 0); + + /// + /// Append a raw byte attributed to and + /// (1-based; 0 means "unknown"). + /// + /// + /// Thrown when is less than 1 (lines are + /// always 1-based and never optional) or when + /// is negative. Columns are 1-based when present, or 0 for + /// "unknown"; negative values are never valid source metadata. + /// + public void WriteByte(byte value, int line, int column) { + ArgumentOutOfRangeException.ThrowIfLessThan(line, 1); + ArgumentOutOfRangeException.ThrowIfNegative(column); _code.Add(value); _lines.Add(line); + _columns.Add(column); } /// Append an opcode byte attributed to . public void WriteOpCode(OpCode opCode, int line) => - WriteByte((byte)opCode, line); + WriteByte((byte)opCode, line, 0); + + /// + /// Append an opcode byte attributed to and + /// (1-based; 0 means "unknown"). + /// + public void WriteOpCode(OpCode opCode, int line, int column) => + WriteByte((byte)opCode, line, column); /// /// Add a value to the constant pool and return its index. diff --git a/src/Grob.Core/GrobArithmeticException.cs b/src/Grob.Core/GrobArithmeticException.cs new file mode 100644 index 0000000..df5640e --- /dev/null +++ b/src/Grob.Core/GrobArithmeticException.cs @@ -0,0 +1,26 @@ +namespace Grob.Core; + +/// +/// Arithmetic runtime error: integer overflow, division by zero, modulo by +/// zero, math domain violations. Maps to the Grob ArithmeticError +/// exception type (D-284). +/// +public sealed class GrobArithmeticException : GrobRuntimeException { + /// + /// Initialises a new with the + /// supplied error , source , + /// and human-readable . + /// is recorded as 0. + /// + public GrobArithmeticException(string code, int line, string message) + : base(code, line, message) { } + + /// + /// Initialises a new with the + /// supplied error , source , + /// 1-based , and human-readable + /// . + /// + public GrobArithmeticException(string code, int line, int column, string message) + : base(code, line, column, message) { } +} diff --git a/src/Grob.Core/GrobRuntimeException.cs b/src/Grob.Core/GrobRuntimeException.cs new file mode 100644 index 0000000..b41c01b --- /dev/null +++ b/src/Grob.Core/GrobRuntimeException.cs @@ -0,0 +1,59 @@ +namespace Grob.Core; + +/// +/// Base type for Grob runtime errors raised by the VM. The two-mode error +/// model (D-284): the compiler/checker collect all errors; the VM stops on +/// the first runtime error. Carries the error code from grob-error-codes.md +/// and the source position (line + column) from the chunk's per-instruction +/// position arrays. is 0 when no column was +/// recorded for the failing instruction. +/// +public class GrobRuntimeException : Exception { + /// The grob-error-codes.md identifier (e.g. E5001). + public string Code { get; } + + /// The source line attributed to the failing instruction. + public int Line { get; } + + /// + /// The 1-based source column attributed to the failing instruction. + /// 0 indicates the chunk byte was written without a column + /// (e.g. hand-built test bytecode or synthetic prologue). + /// + public int Column { get; } + + /// + /// Initialises a new with the supplied + /// error , source , and + /// human-readable . is + /// recorded as 0 ("unknown"); prefer the four-argument constructor + /// when a column is available. + /// + public GrobRuntimeException(string code, int line, string message) + : this(code, line, 0, message) { } + + /// + /// Initialises a new with the supplied + /// error , source , + /// 1-based , and human-readable + /// . + /// + /// + /// Thrown when is or empty, + /// or when is or empty. + /// + /// + /// Thrown when is less than 1 or + /// is negative. + /// + public GrobRuntimeException(string code, int line, int column, string message) + : base(message) { + ArgumentException.ThrowIfNullOrEmpty(code); + ArgumentOutOfRangeException.ThrowIfLessThan(line, 1); + ArgumentOutOfRangeException.ThrowIfNegative(column); + ArgumentException.ThrowIfNullOrEmpty(message); + Code = code; + Line = line; + Column = column; + } +} diff --git a/src/Grob.Vm/ValueStack.cs b/src/Grob.Vm/ValueStack.cs new file mode 100644 index 0000000..500026d --- /dev/null +++ b/src/Grob.Vm/ValueStack.cs @@ -0,0 +1,85 @@ +using Grob.Core; + +namespace Grob.Vm; + +/// +/// The VM operand stack: a fixed-capacity array of +/// slots. Pushing a primitive (Bool/Int/Float) is a 24-byte struct copy with +/// no allocation (D-303, D-304). +/// +/// Authority: grob-vm-architecture.md — value stack section. +/// +public sealed class ValueStack { + /// + /// Maximum simultaneous live values on the operand stack. Chosen to comfortably + /// exceed Sprint 2's needs (no call frames yet) while leaving headroom for + /// future locals and intermediate computation. The value-stack overflow + /// path surfaces as a runtime error, not an unguarded array write. + /// + public const int Capacity = 16384; + + private readonly GrobValue[] _values = new GrobValue[Capacity]; + private int _top; + + /// Number of values currently on the stack. + public int Count => _top; + + /// + /// Push onto the top of the stack. On overflow + /// throws carrying + /// rather than an unguarded array write. + /// + public void Push(GrobValue value, int line) { + if (_top == _values.Length) + throw new GrobRuntimeException("E5903", line, "value stack overflow"); + _values[_top++] = value; + } + + /// + /// Pop and return the top of the stack. Underflow is a compiler/VM bug, + /// not a user-reachable runtime error — surfaces as + /// . + /// + public GrobValue Pop() { + if (_top == 0) + throw new GrobInternalException("value stack underflow"); + var value = _values[--_top]; + _values[_top] = default; // release reference slots for GC (D-304) + return value; + } + + /// + /// Read the value at below the top without + /// popping. distance == 0 is the top. Negative distances and + /// distances past the bottom of the live region are compiler/VM bugs + /// and surface as . + /// + public GrobValue Peek(int distance = 0) { + if (distance < 0) + throw new GrobInternalException("value stack peek with negative distance"); + int index = _top - 1 - distance; + if (index < 0) + throw new GrobInternalException("value stack peek underflow"); + return _values[index]; + } + + /// + /// Logically empty the stack. Clears the live region (slots 0..Count) + /// via so any reference values + /// left over from a prior run are released to the GC (D-304), then resets + /// the top pointer to zero. Used by VirtualMachine.Run to start each + /// invocation from a clean operand stack regardless of any leftovers from + /// a prior exception-terminated run. + /// + internal void Reset() { + if (_top > 0) + Array.Clear(_values, 0, _top); // release reference slots for GC (D-304) + _top = 0; + } + + /// + /// Snapshot the live region of the stack — used by the + /// #if DEBUG trace hook to render the stack each iteration. + /// + internal ReadOnlySpan AsSpan() => _values.AsSpan(0, _top); +} diff --git a/src/Grob.Vm/VirtualMachine.cs b/src/Grob.Vm/VirtualMachine.cs new file mode 100644 index 0000000..85cd3e6 --- /dev/null +++ b/src/Grob.Vm/VirtualMachine.cs @@ -0,0 +1,248 @@ +using Grob.Core; + +namespace Grob.Vm; + +/// +/// The Grob stack-based bytecode VM. Owns the operand stack and the +/// fetch-decode-execute dispatch loop. Sprint 2 Increment B implements the +/// subset of needed to execute hand-constructed chunks +/// up to print(2 + 3 * 4) — see for the supported +/// set; out-of-scope opcodes (control flow, calls, globals, structs, arrays, +/// closures, exceptions, increments, properties, build-string, etc.) raise +/// until their owning increment lands. +/// +/// Authority: grob-vm-architecture.md (dispatch loop, value stack, developer +/// diagnostics) and grob-v1-requirements.md §3.3 (the OpCode set). +/// +public sealed class VirtualMachine { + private readonly ValueStack _stack = new(); + private readonly TextWriter _out; + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4487:Unread \"private\" fields should be removed", + Justification = "Read only inside #if DEBUG by the per-instruction trace hook; appears unread to Release-mode static analysis. Field is required so a single VM instance can be configured with a trace writer regardless of build configuration.")] + private readonly TextWriter _trace; + + /// + /// Construct a VM whose output goes to + /// . receives the + /// #if DEBUG per-instruction trace (defaults to + /// , which is also the only meaningful value + /// in Release where the trace call is compiled out entirely). + /// + public VirtualMachine(TextWriter output, TextWriter? trace = null) { + ArgumentNullException.ThrowIfNull(output); + _out = output; + _trace = trace ?? TextWriter.Null; + } + + /// The operand stack, exposed for tests to inspect post-run state. + public ValueStack Stack => _stack; + + /// + /// Execute until . + /// Running off the end of the bytecode without a + /// is treated as a malformed chunk — it raises + /// , because the compiler always emits + /// a terminating Return and hand-constructed test chunks must do + /// the same. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S3776:Cognitive Complexity of methods should not be too high", + Justification = "Bytecode dispatch loop. Per D-302 each opcode is handled inline in a single switch to keep dispatch branch-free; extracting per-opcode handlers would add a call frame per instruction and is explicitly rejected.")] + public void Run(Chunk chunk) { + ArgumentNullException.ThrowIfNull(chunk); + + // Defensive: a prior Run that terminated by exception may have left + // values on the operand stack. Start every invocation clean so the + // VM behaves the same on the Nth chunk as on the first. + _stack.Reset(); + + int ip = 0; + int line = 0; + int column = 0; + + try { + while (true) { + if (ip >= chunk.Count) + throw new GrobInternalException( + "execution ran past end of chunk without Return"); + + line = chunk.GetLine(ip); + column = chunk.GetColumn(ip); + +#if DEBUG + TraceInstruction(chunk, ip); +#endif + + byte instruction = chunk.ReadByte(ip); + ip++; + + switch ((OpCode)instruction) { + // --- Constants and singletons --- + case OpCode.Constant: { + byte index = chunk.ReadByte(ip++); + _stack.Push(chunk.ReadConstant(index), line); + break; + } + case OpCode.ConstantLong: { + int index = (chunk.ReadByte(ip) << 8) | chunk.ReadByte(ip + 1); + ip += 2; + _stack.Push(chunk.ReadConstant(index), line); + break; + } + case OpCode.Nil: _stack.Push(GrobValue.Nil, line); break; + case OpCode.True: _stack.Push(GrobValue.FromBool(true), line); break; + case OpCode.False: _stack.Push(GrobValue.FromBool(false), line); break; + + case OpCode.Pop: _stack.Pop(); break; + case OpCode.PopN: { + byte count = chunk.ReadByte(ip++); + for (int i = 0; i < count; i++) _stack.Pop(); + break; + } + + // --- Integer arithmetic (checked; OverflowException → E5001) --- + case OpCode.AddInt: { + long b = _stack.Pop().AsInt(); + long a = _stack.Pop().AsInt(); + _stack.Push(GrobValue.FromInt(checked(a + b)), line); + break; + } + case OpCode.SubtractInt: { + long b = _stack.Pop().AsInt(); + long a = _stack.Pop().AsInt(); + _stack.Push(GrobValue.FromInt(checked(a - b)), line); + break; + } + case OpCode.MultiplyInt: { + long b = _stack.Pop().AsInt(); + long a = _stack.Pop().AsInt(); + _stack.Push(GrobValue.FromInt(checked(a * b)), line); + break; + } + case OpCode.DivideInt: { + long b = _stack.Pop().AsInt(); + long a = _stack.Pop().AsInt(); + if (b == 0L) + throw new GrobArithmeticException("E5002", line, column, "integer division by zero"); + // long.MinValue / -1 overflows: caught below as E5001. + _stack.Push(GrobValue.FromInt(checked(a / b)), line); + break; + } + case OpCode.ModuloInt: { + long b = _stack.Pop().AsInt(); + long a = _stack.Pop().AsInt(); + if (b == 0L) + throw new GrobArithmeticException("E5003", line, column, "integer modulo by zero"); + _stack.Push(GrobValue.FromInt(checked(a % b)), line); + break; + } + case OpCode.NegateInt: { + long a = _stack.Pop().AsInt(); + _stack.Push(GrobValue.FromInt(checked(-a)), line); + break; + } + + // --- Float arithmetic (D-273: x / 0.0 and x % 0.0 throw) --- + case OpCode.AddFloat: { + double b = _stack.Pop().AsFloat(); + double a = _stack.Pop().AsFloat(); + _stack.Push(GrobValue.FromFloat(a + b), line); + break; + } + case OpCode.SubtractFloat: { + double b = _stack.Pop().AsFloat(); + double a = _stack.Pop().AsFloat(); + _stack.Push(GrobValue.FromFloat(a - b), line); + break; + } + case OpCode.MultiplyFloat: { + double b = _stack.Pop().AsFloat(); + double a = _stack.Pop().AsFloat(); + _stack.Push(GrobValue.FromFloat(a * b), line); + break; + } + case OpCode.DivideFloat: { + double b = _stack.Pop().AsFloat(); + double a = _stack.Pop().AsFloat(); +#pragma warning disable S1244 // Exact-zero check is intentional per D-273: +0.0/-0.0 both caught, NaN propagates as NaN. + if (b == 0.0) +#pragma warning restore S1244 + throw new GrobArithmeticException("E5004", line, column, "float division by zero"); + _stack.Push(GrobValue.FromFloat(a / b), line); + break; + } + case OpCode.ModuloFloat: { + double b = _stack.Pop().AsFloat(); + double a = _stack.Pop().AsFloat(); +#pragma warning disable S1244 // Exact-zero check is intentional per D-273: +0.0/-0.0 both caught, NaN propagates as NaN. + if (b == 0.0) +#pragma warning restore S1244 + throw new GrobArithmeticException("E5005", line, column, "float modulo by zero"); + _stack.Push(GrobValue.FromFloat(a % b), line); + break; + } + case OpCode.NegateFloat: { + double a = _stack.Pop().AsFloat(); + _stack.Push(GrobValue.FromFloat(-a), line); + break; + } + + // --- Strings --- + case OpCode.Concat: { + string b = _stack.Pop().AsString(); + string a = _stack.Pop().AsString(); + _stack.Push(GrobValue.FromString(string.Concat(a, b)), line); + break; + } + + // --- Promotion --- + case OpCode.IntToFloat: { + long a = _stack.Pop().AsInt(); + _stack.Push(GrobValue.FromFloat(a), line); + break; + } + + // --- I/O --- + case OpCode.Print: + _out.WriteLine(_stack.Pop().ToString()); + break; + + // --- Top-level return ends this chunk's execution --- + case OpCode.Return: + return; + + default: + throw new GrobInternalException( + $"opcode {(OpCode)instruction} not implemented in Sprint 2 Increment B dispatch loop"); + } + } + } catch (OverflowException) { + // Centralised handler for `checked(...)` arithmetic: any int op + // that overflows surfaces as E5001 carrying the failing line. + throw new GrobArithmeticException("E5001", line, column, "integer overflow"); + } + } + +#if DEBUG + /// + /// D-306 per-instruction trace: renders the value stack and the + /// about-to-execute instruction every iteration of the dispatch loop. + /// Compiled into Debug builds only — entirely absent in Release so that + /// D-302 benchmarks measure a branch-free dispatch path. + /// + private void TraceInstruction(Chunk chunk, int ip) { + _trace.Write(" "); + var span = _stack.AsSpan(); + for (int i = 0; i < span.Length; i++) { + _trace.Write("[ "); + _trace.Write(span[i].ToString()); + _trace.Write(" ]"); + } + _trace.WriteLine(); + Disassembler.DisassembleInstruction(chunk, ip, _trace); + } +#endif +} diff --git a/tests/Grob.Core.Tests/ChunkColumnTests.cs b/tests/Grob.Core.Tests/ChunkColumnTests.cs new file mode 100644 index 0000000..352db54 --- /dev/null +++ b/tests/Grob.Core.Tests/ChunkColumnTests.cs @@ -0,0 +1,30 @@ +using Grob.Core; +using Xunit; + +namespace Grob.Core.Tests; + +public class ChunkColumnTests { + [Fact] + public void GetColumn_DefaultOverloads_ReturnZero() { + var chunk = new Chunk(); + chunk.WriteOpCode(OpCode.Return, 7); + chunk.WriteByte(0xAB, 7); + + Assert.Equal(7, chunk.GetLine(0)); + Assert.Equal(0, chunk.GetColumn(0)); + Assert.Equal(7, chunk.GetLine(1)); + Assert.Equal(0, chunk.GetColumn(1)); + } + + [Fact] + public void GetColumn_ColumnAwareOverloads_RoundtripPerByte() { + var chunk = new Chunk(); + chunk.WriteOpCode(OpCode.Constant, line: 12, column: 5); + chunk.WriteByte(0x00, line: 12, column: 14); + chunk.WriteOpCode(OpCode.Return, line: 13, column: 1); + + Assert.Equal((12, 5), (chunk.GetLine(0), chunk.GetColumn(0))); + Assert.Equal((12, 14), (chunk.GetLine(1), chunk.GetColumn(1))); + Assert.Equal((13, 1), (chunk.GetLine(2), chunk.GetColumn(2))); + } +} diff --git a/tests/Grob.Vm.Tests/VirtualMachineTests.cs b/tests/Grob.Vm.Tests/VirtualMachineTests.cs new file mode 100644 index 0000000..3b187ae --- /dev/null +++ b/tests/Grob.Vm.Tests/VirtualMachineTests.cs @@ -0,0 +1,502 @@ +using System.Text; +using Grob.Core; +using Xunit; + +namespace Grob.Vm.Tests; + +/// +/// Dispatch-loop tests — all against hand-constructed chunks. +/// Sprint 2 Increment B: no compiler exists yet; chunks are built directly +/// via and . +/// +public sealed class VirtualMachineTests { + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static (VirtualMachine vm, StringWriter output) NewVm() { + var output = new StringWriter(); + var vm = new VirtualMachine(output); + return (vm, output); + } + + private static byte ConstByte(Chunk chunk, GrobValue value) => + (byte)chunk.AddConstant(value); + + // ------------------------------------------------------------------------- + // The 2 + 3 * 4 chunk — the acceptance witness + // ------------------------------------------------------------------------- + + [Fact] + public void TwoPlusThreeTimesFour_PrintsFourteen() { + var chunk = new Chunk(); + byte i2 = ConstByte(chunk, GrobValue.FromInt(2)); + byte i3 = ConstByte(chunk, GrobValue.FromInt(3)); + byte i4 = ConstByte(chunk, GrobValue.FromInt(4)); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i2, 1); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i3, 1); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i4, 1); + chunk.WriteOpCode(OpCode.MultiplyInt, 1); + chunk.WriteOpCode(OpCode.AddInt, 1); + chunk.WriteOpCode(OpCode.Print, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, output) = NewVm(); + vm.Run(chunk); + + Assert.Equal($"14{Environment.NewLine}", output.ToString()); + Assert.Equal(0, vm.Stack.Count); + } + + [Fact] + public void TwoPlusThreeTimesFour_LeavesFourteenOnStackWhenNoPrint() { + var chunk = new Chunk(); + byte i2 = ConstByte(chunk, GrobValue.FromInt(2)); + byte i3 = ConstByte(chunk, GrobValue.FromInt(3)); + byte i4 = ConstByte(chunk, GrobValue.FromInt(4)); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i2, 1); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i3, 1); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i4, 1); + chunk.WriteOpCode(OpCode.MultiplyInt, 1); + chunk.WriteOpCode(OpCode.AddInt, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, _) = NewVm(); + vm.Run(chunk); + + Assert.Equal(1, vm.Stack.Count); + Assert.Equal(14L, vm.Stack.Peek().AsInt()); + } + + // ------------------------------------------------------------------------- + // Integer arithmetic + // ------------------------------------------------------------------------- + + [Theory] + [InlineData(OpCode.AddInt, 5L, 7L, 12L)] + [InlineData(OpCode.SubtractInt, 10L, 4L, 6L)] + [InlineData(OpCode.MultiplyInt, 6L, 7L, 42L)] + [InlineData(OpCode.DivideInt, 7L, 2L, 3L)] // truncating + [InlineData(OpCode.DivideInt, -7L, 2L, -3L)] // truncating, signed + [InlineData(OpCode.ModuloInt, 7L, 3L, 1L)] + [InlineData(OpCode.ModuloInt, -7L, 3L, -1L)] // sign of dividend + public void IntArithmetic_BinaryOpsProduceExpectedResult(OpCode op, long a, long b, long expected) { + var chunk = new Chunk(); + byte ia = ConstByte(chunk, GrobValue.FromInt(a)); + byte ib = ConstByte(chunk, GrobValue.FromInt(b)); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ia, 1); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ib, 1); + chunk.WriteOpCode(op, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, _) = NewVm(); + vm.Run(chunk); + + Assert.Equal(expected, vm.Stack.Peek().AsInt()); + } + + [Fact] + public void NegateInt_FlipsSign() { + var chunk = new Chunk(); + byte i = ConstByte(chunk, GrobValue.FromInt(42)); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i, 1); + chunk.WriteOpCode(OpCode.NegateInt, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, _) = NewVm(); + vm.Run(chunk); + + Assert.Equal(-42L, vm.Stack.Peek().AsInt()); + } + + // ------------------------------------------------------------------------- + // Float arithmetic + // ------------------------------------------------------------------------- + + [Theory] + [InlineData(OpCode.AddFloat, 1.5, 2.25, 3.75)] + [InlineData(OpCode.SubtractFloat, 3.5, 1.25, 2.25)] + [InlineData(OpCode.MultiplyFloat, 2.0, 2.5, 5.0)] + [InlineData(OpCode.DivideFloat, 7.0, 2.0, 3.5)] + [InlineData(OpCode.ModuloFloat, 7.5, 2.0, 1.5)] + public void FloatArithmetic_BinaryOpsProduceExpectedResult(OpCode op, double a, double b, double expected) { + var chunk = new Chunk(); + byte ia = ConstByte(chunk, GrobValue.FromFloat(a)); + byte ib = ConstByte(chunk, GrobValue.FromFloat(b)); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ia, 1); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ib, 1); + chunk.WriteOpCode(op, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, _) = NewVm(); + vm.Run(chunk); + + Assert.Equal(expected, vm.Stack.Peek().AsFloat()); + } + + [Fact] + public void NegateFloat_FlipsSign() { + var chunk = new Chunk(); + byte i = ConstByte(chunk, GrobValue.FromFloat(3.14)); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i, 1); + chunk.WriteOpCode(OpCode.NegateFloat, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, _) = NewVm(); + vm.Run(chunk); + + Assert.Equal(-3.14, vm.Stack.Peek().AsFloat()); + } + + [Fact] + public void IntToFloat_PromotesIntegerToDouble() { + var chunk = new Chunk(); + byte i = ConstByte(chunk, GrobValue.FromInt(7)); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i, 1); + chunk.WriteOpCode(OpCode.IntToFloat, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, _) = NewVm(); + vm.Run(chunk); + + Assert.True(vm.Stack.Peek().IsFloat); + Assert.Equal(7.0, vm.Stack.Peek().AsFloat()); + } + + // ------------------------------------------------------------------------- + // Concat + // ------------------------------------------------------------------------- + + [Fact] + public void Concat_JoinsTwoStrings() { + var chunk = new Chunk(); + byte ia = ConstByte(chunk, GrobValue.FromString("hello, ")); + byte ib = ConstByte(chunk, GrobValue.FromString("world")); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ia, 1); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ib, 1); + chunk.WriteOpCode(OpCode.Concat, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, _) = NewVm(); + vm.Run(chunk); + + Assert.Equal("hello, world", vm.Stack.Peek().AsString()); + } + + // ------------------------------------------------------------------------- + // Arithmetic errors — carry the correct source line + // ------------------------------------------------------------------------- + + [Fact] + public void IntOverflowOnAdd_ThrowsArithmeticErrorWithLine() { + const int line = 7; + var chunk = new Chunk(); + byte ia = ConstByte(chunk, GrobValue.FromInt(long.MaxValue)); + byte ib = ConstByte(chunk, GrobValue.FromInt(1)); + chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ia, line); + chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ib, line); + chunk.WriteOpCode(OpCode.AddInt, line); + chunk.WriteOpCode(OpCode.Return, line); + + var (vm, _) = NewVm(); + var ex = Assert.Throws(() => vm.Run(chunk)); + Assert.Equal("E5001", ex.Code); + Assert.Equal(line, ex.Line); + } + + [Fact] + public void IntOverflowOnNegateMinValue_ThrowsArithmeticError() { + const int line = 3; + var chunk = new Chunk(); + byte i = ConstByte(chunk, GrobValue.FromInt(long.MinValue)); + chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(i, line); + chunk.WriteOpCode(OpCode.NegateInt, line); + chunk.WriteOpCode(OpCode.Return, line); + + var (vm, _) = NewVm(); + var ex = Assert.Throws(() => vm.Run(chunk)); + Assert.Equal("E5001", ex.Code); + Assert.Equal(line, ex.Line); + } + + [Fact] + public void IntDivideByZero_ThrowsE5002WithLine() { + const int line = 11; + var chunk = new Chunk(); + byte ia = ConstByte(chunk, GrobValue.FromInt(10)); + byte ib = ConstByte(chunk, GrobValue.FromInt(0)); + chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ia, line); + chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ib, line); + chunk.WriteOpCode(OpCode.DivideInt, line); + chunk.WriteOpCode(OpCode.Return, line); + + var (vm, _) = NewVm(); + var ex = Assert.Throws(() => vm.Run(chunk)); + Assert.Equal("E5002", ex.Code); + Assert.Equal(line, ex.Line); + } + + [Fact] + public void IntModuloByZero_ThrowsE5003() { + const int line = 1; + const int column = 9; + var chunk = new Chunk(); + byte ia = ConstByte(chunk, GrobValue.FromInt(10)); + byte ib = ConstByte(chunk, GrobValue.FromInt(0)); + chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ia, line); + chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ib, line); + chunk.WriteOpCode(OpCode.ModuloInt, line, column); + chunk.WriteOpCode(OpCode.Return, line); + + var (vm, _) = NewVm(); + var ex = Assert.Throws(() => vm.Run(chunk)); + Assert.Equal("E5003", ex.Code); + Assert.Equal(line, ex.Line); + Assert.Equal(column, ex.Column); + } + + [Fact] + public void FloatDivideByZero_ThrowsE5004() { + const int line = 1; + const int column = 11; + var chunk = new Chunk(); + byte ia = ConstByte(chunk, GrobValue.FromFloat(1.0)); + byte ib = ConstByte(chunk, GrobValue.FromFloat(0.0)); + chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ia, line); + chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ib, line); + chunk.WriteOpCode(OpCode.DivideFloat, line, column); + chunk.WriteOpCode(OpCode.Return, line); + + var (vm, _) = NewVm(); + var ex = Assert.Throws(() => vm.Run(chunk)); + Assert.Equal("E5004", ex.Code); + Assert.Equal(line, ex.Line); + Assert.Equal(column, ex.Column); + } + + [Fact] + public void FloatModuloByZero_ThrowsE5005() { + const int line = 1; + const int column = 13; + var chunk = new Chunk(); + byte ia = ConstByte(chunk, GrobValue.FromFloat(1.0)); + byte ib = ConstByte(chunk, GrobValue.FromFloat(0.0)); + chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ia, line); + chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ib, line); + chunk.WriteOpCode(OpCode.ModuloFloat, line, column); + chunk.WriteOpCode(OpCode.Return, line); + + var (vm, _) = NewVm(); + var ex = Assert.Throws(() => vm.Run(chunk)); + Assert.Equal("E5005", ex.Code); + Assert.Equal(line, ex.Line); + Assert.Equal(column, ex.Column); + } + + [Fact] + public void VmStopsOnFirstRuntimeError_SubsequentInstructionsNotExecuted() { + var chunk = new Chunk(); + byte ia = ConstByte(chunk, GrobValue.FromInt(1)); + byte ib = ConstByte(chunk, GrobValue.FromInt(0)); + byte hello = ConstByte(chunk, GrobValue.FromString("should-not-print")); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ia, 1); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ib, 1); + chunk.WriteOpCode(OpCode.DivideInt, 1); + chunk.WriteOpCode(OpCode.Constant, 2); chunk.WriteByte(hello, 2); + chunk.WriteOpCode(OpCode.Print, 2); + chunk.WriteOpCode(OpCode.Return, 2); + + var (vm, output) = NewVm(); + Assert.Throws(() => vm.Run(chunk)); + Assert.Equal(string.Empty, output.ToString()); + } + + // ------------------------------------------------------------------------- + // Print display forms + // ------------------------------------------------------------------------- + + [Theory] + [InlineData(GrobValueKind.Int, "42")] + [InlineData(GrobValueKind.Float, "3.14")] + [InlineData(GrobValueKind.Bool, "true")] + [InlineData(GrobValueKind.String, "hello")] + [InlineData(GrobValueKind.Nil, "nil")] + public void Print_WritesDisplayFormOfScalarKinds(GrobValueKind kind, string expected) { + var chunk = new Chunk(); + GrobValue value = kind switch { + GrobValueKind.Int => GrobValue.FromInt(42), + GrobValueKind.Float => GrobValue.FromFloat(3.14), + GrobValueKind.Bool => GrobValue.FromBool(true), + GrobValueKind.String => GrobValue.FromString("hello"), + GrobValueKind.Nil => GrobValue.Nil, + _ => throw new InvalidOperationException(), + }; + + if (kind == GrobValueKind.Nil) { + chunk.WriteOpCode(OpCode.Nil, 1); + } else { + byte ci = ConstByte(chunk, value); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ci, 1); + } + chunk.WriteOpCode(OpCode.Print, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, output) = NewVm(); + vm.Run(chunk); + + Assert.Equal(expected + Environment.NewLine, output.ToString()); + } + + // ------------------------------------------------------------------------- + // ValueStack overflow surfaces as a runtime error + // ------------------------------------------------------------------------- + + [Fact] + public void ValueStackOverflow_SurfacesAsRuntimeErrorNotUnguardedWrite() { + var stack = new ValueStack(); + for (int i = 0; i < ValueStack.Capacity; i++) + stack.Push(GrobValue.FromInt(i), line: 1); + + var ex = Assert.Throws( + () => stack.Push(GrobValue.FromInt(0), line: 99)); + Assert.Equal(99, ex.Line); + Assert.Equal(ValueStack.Capacity, stack.Count); + } + + [Fact] + public void ValueStack_PushPopPeek_Roundtrip() { + var stack = new ValueStack(); + stack.Push(GrobValue.FromInt(1), 1); + stack.Push(GrobValue.FromInt(2), 1); + stack.Push(GrobValue.FromInt(3), 1); + Assert.Equal(3, stack.Count); + Assert.Equal(3L, stack.Peek(0).AsInt()); + Assert.Equal(2L, stack.Peek(1).AsInt()); + Assert.Equal(1L, stack.Peek(2).AsInt()); + Assert.Equal(3L, stack.Pop().AsInt()); + Assert.Equal(2L, stack.Pop().AsInt()); + Assert.Equal(1L, stack.Pop().AsInt()); + Assert.Equal(0, stack.Count); + } + + // ------------------------------------------------------------------------- + // End-without-Return: malformed chunk → GrobInternalException + // ------------------------------------------------------------------------- + + [Fact] + public void ChunkEndingWithoutReturn_RaisesInternalException() { + var chunk = new Chunk(); + byte i = ConstByte(chunk, GrobValue.FromInt(1)); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i, 1); + // No Return appended — deliberately malformed. + + var (vm, _) = NewVm(); + Assert.Throws(() => vm.Run(chunk)); + } + + // ------------------------------------------------------------------------- + // Constants and singletons + // ------------------------------------------------------------------------- + + [Fact] + public void ConstantLong_LoadsFromTwoByteBigEndianIndex() { + var chunk = new Chunk(); + // Fill the pool until we need a 2-byte index. + const int target = 300; + int targetIndex = -1; + for (int i = 0; i <= target; i++) { + int idx = chunk.AddConstant(GrobValue.FromInt(i)); + if (i == target) targetIndex = idx; + } + chunk.WriteOpCode(OpCode.ConstantLong, 1); + chunk.WriteByte((byte)((targetIndex >> 8) & 0xFF), 1); + chunk.WriteByte((byte)(targetIndex & 0xFF), 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, _) = NewVm(); + vm.Run(chunk); + + Assert.Equal((long)target, vm.Stack.Peek().AsInt()); + } + + [Fact] + public void NilTrueFalse_PushSingletons() { + var chunk = new Chunk(); + chunk.WriteOpCode(OpCode.Nil, 1); + chunk.WriteOpCode(OpCode.True, 1); + chunk.WriteOpCode(OpCode.False, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, _) = NewVm(); + vm.Run(chunk); + + Assert.Equal(3, vm.Stack.Count); + Assert.False(vm.Stack.Peek(0).AsBool()); + Assert.True(vm.Stack.Peek(1).AsBool()); + Assert.True(vm.Stack.Peek(2).IsNil); + } + + [Fact] + public void Pop_DiscardsTopOfStack() { + var chunk = new Chunk(); + byte ia = ConstByte(chunk, GrobValue.FromInt(1)); + byte ib = ConstByte(chunk, GrobValue.FromInt(2)); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ia, 1); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ib, 1); + chunk.WriteOpCode(OpCode.Pop, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, _) = NewVm(); + vm.Run(chunk); + + Assert.Equal(1, vm.Stack.Count); + Assert.Equal(1L, vm.Stack.Peek().AsInt()); + } + + [Fact] + public void PopN_DiscardsCountValues() { + var chunk = new Chunk(); + for (int i = 0; i < 4; i++) { + byte ci = ConstByte(chunk, GrobValue.FromInt(i)); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ci, 1); + } + chunk.WriteOpCode(OpCode.PopN, 1); chunk.WriteByte(3, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var (vm, _) = NewVm(); + vm.Run(chunk); + + Assert.Equal(1, vm.Stack.Count); + Assert.Equal(0L, vm.Stack.Peek().AsInt()); + } + +#if DEBUG + // ------------------------------------------------------------------------- + // D-306 trace hook — Debug-only behaviour assertion + // ------------------------------------------------------------------------- + + [Fact] + public void TraceInstructionInDebug_WritesStackAndDisassemblyEveryIteration() { + var chunk = new Chunk(); + byte i2 = ConstByte(chunk, GrobValue.FromInt(2)); + byte i3 = ConstByte(chunk, GrobValue.FromInt(3)); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i2, 1); + chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i3, 1); + chunk.WriteOpCode(OpCode.AddInt, 1); + chunk.WriteOpCode(OpCode.Return, 1); + + var output = new StringWriter(); + var trace = new StringWriter(); + var vm = new VirtualMachine(output, trace); + vm.Run(chunk); + + string traced = trace.ToString(); + // Stack rendering markers and opcode names from the disassembler. + Assert.Contains("[ 2 ]", traced); + Assert.Contains("[ 2 ][ 3 ]", traced); + Assert.Contains("Constant", traced); + Assert.Contains("AddInt", traced); + Assert.Contains("Return", traced); + } +#endif +}