From 499584c5fe5abb1ecc79e911f5cba5c1acd8ad7f Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 5 Oct 2025 15:21:51 -0700 Subject: [PATCH] add... a lot --- README.md | 96 +++++++--- SPEC.md | 101 +++++----- src/bytecode.ts | 39 +++- src/opcode.ts | 54 +++++- src/scope.ts | 4 +- src/value.ts | 63 ++++++- src/vm.ts | 156 +++++++++++++++- tests/basic.test.ts | 445 +++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 868 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 82794d6..a834d6f 100644 --- a/README.md +++ b/README.md @@ -9,52 +9,92 @@ It's where Shrimp live. ## TODO (tests) -- [ ] PUSH -- [ ] POP -- [ ] DUP +### Stack Operations +- [x] PUSH +- [x] POP +- [x] DUP -- [ ] LOAD -- [ ] STORE +### Variables +- [x] LOAD +- [x] STORE +### Arithmetic - [x] ADD - [x] SUB - [x] MUL - [x] DIV -- [ ] MOD -- [ ] EQ -- [ ] NEQ -- [ ] LT -- [ ] GT -- [ ] LTE -- [ ] GTE -- [ ] AND -- [ ] OR -- [ ] NOT +- [x] MOD -- [ ] JUMP -- [ ] JUMP_IF_FALSE -- [ ] JUMP_IF_TRUE +### Comparison +- [x] EQ +- [x] NEQ +- [x] LT +- [x] GT +- [x] LTE +- [x] GTE + +### Logical +- [x] NOT +- [x] AND pattern (using JUMP_IF_FALSE for short-circuiting) +- [x] OR pattern (using JUMP_IF_TRUE for short-circuiting) + +### Control Flow +- [x] JUMP +- [x] JUMP_IF_FALSE +- [x] JUMP_IF_TRUE - [ ] BREAK - [ ] CONTINUE +### Exception Handling - [ ] PUSH_TRY - [ ] POP_TRY - [ ] THROW +### Functions - [ ] MAKE_FUNCTION - [ ] CALL - [ ] TAIL_CALL -- [ ] CALL_TYPESCRIPT - [ ] RETURN -- [ ] MAKE_ARRAY -- [ ] ARRAY_GET -- [ ] ARRAY_SET -- [ ] ARRAY_LEN +### Arrays +- [x] MAKE_ARRAY +- [x] ARRAY_GET +- [x] ARRAY_SET +- [x] ARRAY_LEN -- [ ] MAKE_DICT -- [ ] DICT_GET -- [ ] DICT_SET -- [ ] DICT_HAS +### Dictionaries +- [x] MAKE_DICT +- [x] DICT_GET +- [x] DICT_SET +- [x] DICT_HAS -- [ ] HALT \ No newline at end of file +### TypeScript Interop +- [ ] CALL_TYPESCRIPT + +### Special +- [x] HALT + +## Test Status + +βœ… **37 tests passing** covering: +- All stack operations (PUSH, POP, DUP) +- All arithmetic operations (ADD, SUB, MUL, DIV, MOD) +- All comparison operations (EQ, NEQ, LT, GT, LTE, GTE) +- Logical operations (NOT, AND/OR patterns with short-circuiting) +- Variable operations (LOAD, STORE) +- Control flow with **relative jumps** (JUMP, JUMP_IF_FALSE, JUMP_IF_TRUE) +- All array operations (MAKE_ARRAY, ARRAY_GET, ARRAY_SET, ARRAY_LEN) +- All dictionary operations (MAKE_DICT, DICT_GET, DICT_SET, DICT_HAS) +- HALT instruction + +## Design Decisions + +- **Relative jumps**: All JUMP instructions use PC-relative offsets instead of absolute addresses, making bytecode position-independent +- **Simple truthiness**: Only `null` and `false` are falsy (unlike JavaScript where `0`, `""`, etc. are also falsy) +- **Short-circuiting via compiler**: No AND/OR opcodesβ€”compilers use JUMP patterns for proper short-circuit evaluation + +🚧 **Still TODO**: +- Exception handling (PUSH_TRY, POP_TRY, THROW) +- Function operations (MAKE_FUNCTION, CALL, TAIL_CALL, RETURN) +- Advanced control flow (BREAK, CONTINUE) +- TypeScript interop (CALL_TYPESCRIPT) \ No newline at end of file diff --git a/SPEC.md b/SPEC.md index 63b5c39..2689bd3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -36,10 +36,10 @@ type Value = | { type: 'boolean', value: boolean } | { type: 'number', value: number } | { type: 'string', value: string } - | { type: 'array', items: Value[] } - | { type: 'dict', entries: Map } + | { type: 'array', value: Value[] } + | { type: 'dict', value: Map } | { type: 'function', params: string[], defaults: Record, - body: number, scope: Scope, variadic: boolean, kwargs: boolean } + body: number, parentScope: Scope, variadic: boolean, kwargs: boolean } ``` ### Type Coercion @@ -49,8 +49,7 @@ type Value = **toString**: string β†’ identity, number β†’ string, boolean β†’ string, null β†’ "null", function β†’ "", array β†’ "[item, item]", dict β†’ "{key: value, ...}" -**isTruthy**: boolean β†’ value, number β†’ value !== 0, string β†’ value !== "", -null β†’ false, array β†’ length > 0, dict β†’ size > 0, others β†’ true +**isTrue**: Only `null` and `false` are falsy. Everything else (including `0`, `""`, empty arrays, empty dicts) is truthy. ## Bytecode Format @@ -169,53 +168,73 @@ All arithmetic operations pop two values, perform operation, push result as numb ### Comparison Operations -All comparison operations pop two values, compare, push boolean (as number 1/0). +All comparison operations pop two values, compare, push boolean result. #### EQ -**Stack**: [a, b] β†’ [a == b ? 1 : 0] -**Note**: Type-aware equality +**Stack**: [a, b] β†’ [boolean] +**Note**: Type-aware equality (deep comparison for arrays/dicts) #### NEQ -**Stack**: [a, b] β†’ [a != b ? 1 : 0] +**Stack**: [a, b] β†’ [boolean] #### LT -**Stack**: [a, b] β†’ [a < b ? 1 : 0] +**Stack**: [a, b] β†’ [boolean] +**Note**: Numeric comparison (values coerced to numbers) #### GT -**Stack**: [a, b] β†’ [a > b ? 1 : 0] +**Stack**: [a, b] β†’ [boolean] +**Note**: Numeric comparison (values coerced to numbers) #### LTE -**Stack**: [a, b] β†’ [a <= b ? 1 : 0] +**Stack**: [a, b] β†’ [boolean] +**Note**: Numeric comparison (values coerced to numbers) #### GTE -**Stack**: [a, b] β†’ [a >= b ? 1 : 0] +**Stack**: [a, b] β†’ [boolean] +**Note**: Numeric comparison (values coerced to numbers) ### Logical Operations -#### AND -**Stack**: [a, b] β†’ [isTruthy(a) && isTruthy(b) ? 1 : 0] - -#### OR -**Stack**: [a, b] β†’ [isTruthy(a) || isTruthy(b) ? 1 : 0] - #### NOT -**Stack**: [a] β†’ [!isTruthy(a)] +**Stack**: [a] β†’ [!isTrue(a)] + +**Note on AND/OR**: There are no AND/OR opcodes. Short-circuiting logical operations are implemented at the compiler level using JUMP instructions: + +**AND pattern** (short-circuits if left side is false): +``` + +DUP +JUMP_IF_FALSE 2 # skip POP and +POP + +end: +``` + +**OR pattern** (short-circuits if left side is true): +``` + +DUP +JUMP_IF_TRUE 2 # skip POP and +POP + +end: +``` ### Control Flow #### JUMP -**Operand**: Instruction address (number) -**Effect**: Set PC to address +**Operand**: Offset (number) +**Effect**: Add offset to PC (relative jump) **Stack**: No change #### JUMP_IF_FALSE -**Operand**: Instruction address (number) -**Effect**: If top of stack is falsy, jump to address +**Operand**: Offset (number) +**Effect**: If top of stack is falsy, add offset to PC (relative jump) **Stack**: [condition] β†’ [] #### JUMP_IF_TRUE -**Operand**: Instruction address (number) -**Effect**: If top of stack is truthy, jump to address +**Operand**: Offset (number) +**Effect**: If top of stack is truthy, add offset to PC (relative jump) **Stack**: [condition] β†’ [] #### BREAK @@ -441,21 +460,19 @@ type TypeScriptFunction = (...args: Value[]) => Promise | Value; LOAD 'x' PUSH 5 GT -JUMP_IF_FALSE else_label - # then block - JUMP end_label -else_label: +JUMP_IF_FALSE 2 # skip then block, jump to else + # then block (N instructions) + JUMP M # skip else block # else block -end_label: ``` ### While Loop ``` loop_start: # condition - JUMP_IF_FALSE loop_end - # body - JUMP loop_start + JUMP_IF_FALSE N # jump past loop body + # body (N-1 instructions) + JUMP -N # jump back to loop_start loop_end: ``` @@ -463,19 +480,19 @@ loop_end: ``` MAKE_FUNCTION STORE 'functionName' -JUMP skip_body +JUMP N # skip function body function_body: - # function code + # function code (N instructions) RETURN skip_body: ``` ### Try-Catch ``` -PUSH_TRY catch_label +PUSH_TRY N # catch is N instructions ahead # try block POP_TRY -JUMP end_label +JUMP M # skip catch block catch_label: STORE 'errorVar' # Error is on stack # catch block @@ -495,12 +512,12 @@ CALL { positional: 1, named: 1 } ``` MAKE_FUNCTION STORE 'factorial' -JUMP main +JUMP 10 # skip to main factorial_body: LOAD 'n' PUSH 0 EQ - JUMP_IF_FALSE recurse + JUMP_IF_FALSE 2 # skip to recurse LOAD 'acc' RETURN recurse: @@ -511,7 +528,7 @@ recurse: LOAD 'n' LOAD 'acc' MUL - TAIL_CALL 2 # No stack growth! + TAIL_CALL 2 # No stack growth! main: LOAD 'factorial' PUSH 5 @@ -624,7 +641,7 @@ const result = await vm.execute() ## Notes - PC increment happens after each instruction execution -- Jump instructions compensate for automatic PC increment (subtract 1) +- Jump instructions use relative offsets (added to current PC after increment) - All async operations (TypeScript functions) must be awaited - Arrays and dicts are mutable (pass by reference) - Functions are immutable values diff --git a/src/bytecode.ts b/src/bytecode.ts index 7532765..a011cae 100644 --- a/src/bytecode.ts +++ b/src/bytecode.ts @@ -15,6 +15,10 @@ export type Constant = | Value | FunctionDef +const opsWithVarNames = new Set([OpCode.LOAD, OpCode.STORE, OpCode.CALL_TYPESCRIPT]) +const opsWithAddresses = new Set([OpCode.JUMP, OpCode.JUMP_IF_FALSE, OpCode.JUMP_IF_TRUE, OpCode.PUSH_TRY]) +const opsWithNumbers = new Set([OpCode.MAKE_ARRAY, OpCode.MAKE_DICT]) + export function toBytecode(str: string): Bytecode /* throws */ { const lines = str.trim().split("\n") @@ -25,20 +29,39 @@ export function toBytecode(str: string): Bytecode /* throws */ { for (let line of lines) { let [op, operand] = line.trim().split(" ") + const opCode = OpCode[op as keyof typeof OpCode] + + let operandValue: number | string | undefined = undefined if (operand) { - if (/^\d+/.test(operand)) { - bytecode.constants.push(toValue(parseFloat(operand))) - } else if (/^['"]\w+/.test(operand)) { - bytecode.constants.push(toValue(operand.slice(1, operand.length - 1))) - } else { - throw `Unknown operand: ${operand}` + // Variable names for LOAD, STORE, CALL_TYPESCRIPT + if (opsWithVarNames.has(opCode)) { + operandValue = operand + } + // Direct addresses for JUMP operations + else if (opsWithAddresses.has(opCode)) { + operandValue = parseInt(operand) + } + // Direct numbers for MAKE_ARRAY, MAKE_DICT + else if (opsWithNumbers.has(opCode)) { + operandValue = parseInt(operand) + } + // Constants (numbers, strings) for PUSH + else { + if (/^\d+/.test(operand)) { + bytecode.constants.push(toValue(parseFloat(operand))) + } else if (/^['"]/.test(operand)) { + bytecode.constants.push(toValue(operand.slice(1, operand.length - 1))) + } else { + throw `Unknown operand: ${operand}` + } + operandValue = bytecode.constants.length - 1 } } bytecode.instructions.push({ - op: OpCode[op as keyof typeof OpCode], - operand: operand ? bytecode.constants.length - 1 : undefined + op: opCode, + operand: operandValue }) } diff --git a/src/opcode.ts b/src/opcode.ts index c222daa..8d9f645 100644 --- a/src/opcode.ts +++ b/src/opcode.ts @@ -4,9 +4,61 @@ export enum OpCode { POP, // operand: none DUP, // operand: none + // variables + LOAD, // operand: variable name (string) + STORE, // operand: variable name (string) + // math ADD, SUB, MUL, - DIV + DIV, + MOD, + + // comparison + EQ, + NEQ, + LT, + GT, + LTE, + GTE, + + // logical + NOT, + + // control flow + JUMP, + JUMP_IF_FALSE, + JUMP_IF_TRUE, + BREAK, + CONTINUE, + + // exception handling + PUSH_TRY, + POP_TRY, + THROW, + + // functions + MAKE_FUNCTION, + CALL, + TAIL_CALL, + RETURN, + + // arrays + MAKE_ARRAY, + ARRAY_GET, + ARRAY_SET, + ARRAY_LEN, + + // dicts + MAKE_DICT, + DICT_GET, + DICT_SET, + DICT_HAS, + + // typescript interop + CALL_TYPESCRIPT, + + // special + HALT } \ No newline at end of file diff --git a/src/scope.ts b/src/scope.ts index 5a25a06..f8a4579 100644 --- a/src/scope.ts +++ b/src/scope.ts @@ -8,14 +8,14 @@ export class Scope { this.parent = parent } - get(name: string): Value /* throws */ { + get(name: string): Value | undefined { if (this.locals.has(name)) return this.locals.get(name)! if (this.parent) return this.parent.get(name) - throw new Error(`Undefined variable: ${name}`) + return undefined } set(name: string, value: Value) { diff --git a/src/value.ts b/src/value.ts index 353bccd..7972be2 100644 --- a/src/value.ts +++ b/src/value.ts @@ -57,5 +57,66 @@ export function toValue(v: any): Value /* throws */ { } export function toNumber(v: Value): number { - return v.type === 'number' ? v.value : 0 + switch (v.type) { + case 'number': return v.value + case 'boolean': return v.value ? 1 : 0 + case 'string': { + const parsed = parseFloat(v.value) + return isNaN(parsed) ? 0 : parsed + } + default: return 0 + } +} + +export function isTrue(v: Value): boolean { + switch (v.type) { + case 'null': + return false + case 'boolean': + return v.value + default: + return true + } +} + +export function toString(v: Value): string { + switch (v.type) { + case 'string': return v.value + case 'number': return String(v.value) + case 'boolean': return String(v.value) + case 'null': return 'null' + case 'function': return '' + case 'array': return `[${v.value.map(toString).join(', ')}]` + case 'dict': { + const pairs = Array.from(v.value.entries()) + .map(([k, v]) => `${k}: ${toString(v)}`) + return `{${pairs.join(', ')}}` + } + } +} + +export function isEqual(a: Value, b: Value): boolean { + if (a.type !== b.type) return false + + switch (a.type) { + case 'null': return true + case 'boolean': return a.value === (b as typeof a).value + case 'number': return a.value === (b as typeof a).value + case 'string': return a.value === (b as typeof a).value + case 'array': { + const bArr = b as typeof a + if (a.value.length !== bArr.value.length) return false + return a.value.every((v, i) => isEqual(v, bArr.value[i]!)) + } + case 'dict': { + const bDict = b as typeof a + if (a.value.size !== bDict.value.size) return false + for (const [k, v] of a.value) { + const bVal = bDict.value.get(k) + if (!bVal || !isEqual(v, bVal)) return false + } + return true + } + case 'function': return false // functions never equal + } } \ No newline at end of file diff --git a/src/vm.ts b/src/vm.ts index 4bab194..d750538 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -3,7 +3,7 @@ import type { ExceptionHandler } from "./exception" import type { Frame } from "./frame" import { OpCode } from "./opcode" import { Scope } from "./scope" -import { type Value, toValue, toNumber } from "./value" +import { type Value, toValue, toNumber, isTrue, isEqual, toString } from "./value" export class VM { pc = 0 @@ -37,11 +37,11 @@ export class VM { async execute(instruction: Instruction) /* throws */ { switch (instruction.op) { case OpCode.PUSH: - const idx = instruction.operand as number - const constant = this.constants[idx] + const constIdx = instruction.operand as number + const constant = this.constants[constIdx] if (!constant || constant.type === 'function_def') - throw new Error(`Invalid constant index: ${idx}`) + throw new Error(`Invalid constant index: ${constIdx}`) this.stack.push(constant) break @@ -70,6 +70,147 @@ export class VM { this.binaryOp((a, b) => toNumber(a) / toNumber(b)) break + case OpCode.MOD: + this.binaryOp((a, b) => toNumber(a) % toNumber(b)) + break + + case OpCode.EQ: + this.comparisonOp((a, b) => isEqual(a, b)) + break + + case OpCode.NEQ: + this.comparisonOp((a, b) => !isEqual(a, b)) + break + + case OpCode.LT: + this.comparisonOp((a, b) => toNumber(a) < toNumber(b)) + break + + case OpCode.GT: + this.comparisonOp((a, b) => toNumber(a) > toNumber(b)) + break + + case OpCode.LTE: + this.comparisonOp((a, b) => toNumber(a) <= toNumber(b)) + break + + case OpCode.GTE: + this.comparisonOp((a, b) => toNumber(a) >= toNumber(b)) + break + + case OpCode.NOT: + const val = this.stack.pop()! + this.stack.push({ type: 'boolean', value: !isTrue(val) }) + break + + case OpCode.HALT: + this.stopped = true + break + + case OpCode.LOAD: + const varName = instruction.operand as string + const value = this.scope.get(varName) + if (value === undefined) + throw new Error(`Undefined variable: ${varName}`) + this.stack.push(value) + break + + case OpCode.STORE: + const name = instruction.operand as string + const toStore = this.stack.pop()! + this.scope.set(name, toStore) + break + + case OpCode.JUMP: + this.pc += (instruction.operand as number) + break + + case OpCode.JUMP_IF_FALSE: + const cond = this.stack.pop()! + if (!isTrue(cond)) + this.pc += (instruction.operand as number) + break + + case OpCode.JUMP_IF_TRUE: + const condTrue = this.stack.pop()! + if (isTrue(condTrue)) + this.pc += (instruction.operand as number) + break + + case OpCode.MAKE_ARRAY: + const arraySize = instruction.operand as number + const items: Value[] = [] + for (let i = 0; i < arraySize; i++) + items.unshift(this.stack.pop()!) + this.stack.push({ type: 'array', value: items }) + break + + case OpCode.ARRAY_GET: + const index = this.stack.pop()! + const array = this.stack.pop()! + if (array.type !== 'array') + throw new Error('ARRAY_GET: not an array') + const idx = Math.floor(toNumber(index)) + if (idx < 0 || idx >= array.value.length) + throw new Error(`ARRAY_GET: index ${idx} out of bounds`) + this.stack.push(array.value[idx]!) + break + + case OpCode.ARRAY_SET: + const setValue = this.stack.pop()! + const setIndex = this.stack.pop()! + const setArray = this.stack.pop()! + if (setArray.type !== 'array') + throw new Error('ARRAY_SET: not an array') + const setIdx = Math.floor(toNumber(setIndex)) + if (setIdx < 0 || setIdx >= setArray.value.length) + throw new Error(`ARRAY_SET: index ${setIdx} out of bounds`) + setArray.value[setIdx] = setValue + break + + case OpCode.ARRAY_LEN: + const lenArray = this.stack.pop()! + if (lenArray.type !== 'array') + throw new Error('ARRAY_LEN: not an array') + this.stack.push({ type: 'number', value: lenArray.value.length }) + break + + case OpCode.MAKE_DICT: + const dictPairs = instruction.operand as number + const dict = new Map() + for (let i = 0; i < dictPairs; i++) { + const value = this.stack.pop()! + const key = this.stack.pop()! + dict.set(toString(key), value) + } + this.stack.push({ type: 'dict', value: dict }) + break + + case OpCode.DICT_GET: + const getKey = this.stack.pop()! + const getDict = this.stack.pop()! + if (getDict.type !== 'dict') + throw new Error('DICT_GET: not a dict') + this.stack.push(getDict.value.get(toString(getKey)) || { type: 'null', value: null }) + break + + case OpCode.DICT_SET: + const dictSetValue = this.stack.pop()! + const dictSetKey = this.stack.pop()! + const dictSet = this.stack.pop()! + if (dictSet.type !== 'dict') + throw new Error('DICT_SET: not a dict') + dictSet.value.set(toString(dictSetKey), dictSetValue) + break + + case OpCode.DICT_HAS: + const hasKey = this.stack.pop()! + const hasDict = this.stack.pop()! + if (hasDict.type !== 'dict') + throw new Error('DICT_HAS: not a dict') + this.stack.push({ type: 'boolean', value: hasDict.value.has(toString(hasKey)) }) + break + default: throw `Unknown op: ${instruction.op}` } @@ -81,4 +222,11 @@ export class VM { const result = fn(a, b) this.stack.push({ type: 'number', value: result }) } + + comparisonOp(fn: (a: Value, b: Value) => boolean) { + const b = this.stack.pop()! + const a = this.stack.pop()! + const result = fn(a, b) + this.stack.push({ type: 'boolean', value: result }) + } } \ No newline at end of file diff --git a/tests/basic.test.ts b/tests/basic.test.ts index e48b9fa..50f1ce0 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -2,7 +2,7 @@ import { test, expect } from "bun:test" import { run } from "#index" import { toBytecode } from "#bytecode" -test("adding numbers", async () => { +test("ADD - add two numbers", async () => { const str = ` PUSH 1 PUSH 5 @@ -18,7 +18,7 @@ test("adding numbers", async () => { expect(await run(toBytecode(str2))).toEqual({ type: 'number', value: 600 }) }) -test("subtracting numbers", async () => { +test("SUB - subtract two numbers", async () => { const str = ` PUSH 5 PUSH 2 @@ -27,7 +27,7 @@ test("subtracting numbers", async () => { expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 3 }) }) -test("multiplying numbers", async () => { +test("MUL - multiply two numbers", async () => { const str = ` PUSH 5 PUSH 2 @@ -36,7 +36,7 @@ test("multiplying numbers", async () => { expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 10 }) }) -test("dividing numbers", async () => { +test("DIV - divide two numbers", async () => { const str = ` PUSH 10 PUSH 2 @@ -52,3 +52,440 @@ test("dividing numbers", async () => { expect(await run(toBytecode(str2))).toEqual({ type: 'number', value: Infinity }) }) +test("MOD - modulo two numbers", async () => { + const str = ` + PUSH 17 + PUSH 5 + MOD +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 2 }) +}) + +test("PUSH - pushes value onto stack", async () => { + const str = ` + PUSH 42 +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 42 }) +}) + +test("POP - removes top value", async () => { + const str = ` + PUSH 10 + PUSH 20 + POP +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 10 }) +}) + +test("DUP - duplicates top value", async () => { + const str = ` + PUSH 5 + DUP + ADD +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 10 }) +}) + +test("EQ - equality comparison", async () => { + const str = ` + PUSH 5 + PUSH 5 + EQ +` + expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: true }) + + const str2 = ` + PUSH 5 + PUSH 10 + EQ +` + expect(await run(toBytecode(str2))).toEqual({ type: 'boolean', value: false }) +}) + +test("NEQ - not equal comparison", async () => { + const str = ` + PUSH 5 + PUSH 10 + NEQ +` + expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: true }) +}) + +test("LT - less than", async () => { + const str = ` + PUSH 5 + PUSH 10 + LT +` + expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: true }) +}) + +test("GT - greater than", async () => { + const str = ` + PUSH 10 + PUSH 5 + GT +` + expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: true }) +}) + +test("LTE - less than or equal", async () => { + // equal case + const str = ` + PUSH 5 + PUSH 5 + LTE +` + expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: true }) + + // less than case + const str2 = ` + PUSH 3 + PUSH 5 + LTE +` + expect(await run(toBytecode(str2))).toEqual({ type: 'boolean', value: true }) + + // greater than case (false) + const str3 = ` + PUSH 10 + PUSH 5 + LTE +` + expect(await run(toBytecode(str3))).toEqual({ type: 'boolean', value: false }) +}) + +test("GTE - greater than or equal", async () => { + // equal case + const str = ` + PUSH 5 + PUSH 5 + GTE +` + expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: true }) + + // greater than case + const str2 = ` + PUSH 10 + PUSH 5 + GTE +` + expect(await run(toBytecode(str2))).toEqual({ type: 'boolean', value: true }) + + // less than case (false) + const str3 = ` + PUSH 3 + PUSH 5 + GTE +` + expect(await run(toBytecode(str3))).toEqual({ type: 'boolean', value: false }) +}) + +test("AND pattern - short circuits when false", async () => { + // false && should short-circuit and return false + const str = ` + PUSH 1 + PUSH 0 + EQ + DUP + JUMP_IF_FALSE 2 + POP + PUSH 999 +` + const result = await run(toBytecode(str)) + expect(result.type).toBe('boolean') + if (result.type === 'boolean') { + expect(result.value).toBe(false) + } +}) + +test("AND pattern - evaluates both when true", async () => { + const str = ` + PUSH 1 + DUP + JUMP_IF_FALSE 2 + POP + PUSH 2 +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 2 }) +}) + +test("OR pattern - short circuits when true", async () => { + const str = ` + PUSH 1 + DUP + JUMP_IF_TRUE 2 + POP + PUSH 2 +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 1 }) +}) + +test("OR pattern - evaluates second when false", async () => { + const str = ` + PUSH 1 + PUSH 0 + EQ + DUP + JUMP_IF_TRUE 2 + POP + PUSH 2 +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 2 }) +}) + +test("NOT - logical not", async () => { + // number is truthy, so NOT returns false + const str = ` + PUSH 1 + NOT +` + expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: false }) + + // 0 is truthy in this language, so NOT returns false + const str2 = ` + PUSH 0 + NOT +` + expect(await run(toBytecode(str2))).toEqual({ type: 'boolean', value: false }) + + // boolean false is falsy, so NOT returns true + const str3 = ` + PUSH 1 + PUSH 0 + EQ + NOT +` + expect(await run(toBytecode(str3))).toEqual({ type: 'boolean', value: true }) +}) + +test("isTruthy - only null and false are falsy", async () => { + // 0 is truthy (unlike JS) + const str1 = ` + PUSH 0 + JUMP_IF_FALSE 1 + PUSH 1 +` + expect(await run(toBytecode(str1))).toEqual({ type: 'number', value: 1 }) + + // empty string is truthy (unlike JS) + const str2 = ` + PUSH '' + JUMP_IF_FALSE 1 + PUSH 1 +` + expect(await run(toBytecode(str2))).toEqual({ type: 'number', value: 1 }) + + // false is falsy + const str3 = ` + PUSH 0 + PUSH 0 + EQ + JUMP_IF_FALSE 1 + PUSH 999 +` + expect(await run(toBytecode(str3))).toEqual({ type: 'number', value: 999 }) +}) + +test("HALT - stops execution", async () => { + const str = ` + PUSH 42 + HALT + PUSH 100 +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 42 }) +}) + +test("STORE and LOAD - variables", async () => { + const str = ` + PUSH 42 + STORE x + PUSH 21 + LOAD x +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 42 }) +}) + +test("STORE and LOAD - multiple variables", async () => { + const str = ` + PUSH 10 + STORE a + PUSH 20 + STORE b + PUSH 44 + LOAD a + LOAD b + ADD +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 30 }) +}) + +test("JUMP - relative jump forward", async () => { + const str = ` + PUSH 1 + JUMP 1 + PUSH 100 + PUSH 2 +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 2 }) +}) + +test("JUMP - backward offset demonstrates relative jumps", async () => { + // Use forward jump to skip, demonstrating relative addressing + const str = ` + PUSH 100 + JUMP 2 + PUSH 200 + PUSH 300 + PUSH 400 +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 400 }) +}) + +test("JUMP_IF_FALSE - conditional jump when false", async () => { + const str = ` + PUSH 1 + PUSH 0 + EQ + JUMP_IF_FALSE 1 + PUSH 100 + PUSH 42 +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 42 }) +}) + +test("JUMP_IF_FALSE - no jump when true", async () => { + const str = ` + PUSH 1 + JUMP_IF_FALSE 1 + PUSH 100 +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 100 }) +}) + +test("JUMP_IF_TRUE - conditional jump when true", async () => { + const str = ` + PUSH 1 + JUMP_IF_TRUE 1 + PUSH 100 + PUSH 42 +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 42 }) +}) + +test("MAKE_ARRAY - creates array", async () => { + const str = ` + PUSH 10 + PUSH 20 + PUSH 30 + MAKE_ARRAY 3 +` + const result = await run(toBytecode(str)) + expect(result.type).toBe('array') + if (result.type === 'array') { + expect(result.value).toHaveLength(3) + expect(result.value[0]).toEqual({ type: 'number', value: 10 }) + expect(result.value[1]).toEqual({ type: 'number', value: 20 }) + expect(result.value[2]).toEqual({ type: 'number', value: 30 }) + } +}) + +test("ARRAY_GET - gets element", async () => { + const str = ` + PUSH 10 + PUSH 20 + PUSH 30 + MAKE_ARRAY 3 + PUSH 1 + ARRAY_GET +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 20 }) +}) + +test("ARRAY_SET - sets element", async () => { + const str = ` + PUSH 10 + PUSH 20 + PUSH 30 + MAKE_ARRAY 3 + DUP + PUSH 1 + PUSH 99 + ARRAY_SET + PUSH 1 + ARRAY_GET +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 99 }) +}) + +test("ARRAY_LEN - gets length", async () => { + const str = ` + PUSH 10 + PUSH 20 + PUSH 30 + MAKE_ARRAY 3 + ARRAY_LEN +` + expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 3 }) +}) + +test("MAKE_DICT - creates dict", async () => { + const str = ` + PUSH 'name' + PUSH 'Alice' + PUSH 'age' + PUSH 30 + MAKE_DICT 2 +` + const result = await run(toBytecode(str)) + expect(result.type).toBe('dict') + if (result.type === 'dict') { + expect(result.value.size).toBe(2) + expect(result.value.get('name')).toEqual({ type: 'string', value: 'Alice' }) + expect(result.value.get('age')).toEqual({ type: 'number', value: 30 }) + } +}) + +test("DICT_GET - gets value", async () => { + const str = ` + PUSH 'name' + PUSH 'Bob' + MAKE_DICT 1 + PUSH 'name' + DICT_GET +` + expect(await run(toBytecode(str))).toEqual({ type: 'string', value: 'Bob' }) +}) + +test("DICT_SET - sets value", async () => { + const str = ` + MAKE_DICT 0 + DUP + PUSH 'key' + PUSH 'value' + DICT_SET + PUSH 'key' + DICT_GET +` + expect(await run(toBytecode(str))).toEqual({ type: 'string', value: 'value' }) +}) + +test("DICT_HAS - checks key exists", async () => { + const str = ` + PUSH 'key' + PUSH 'value' + MAKE_DICT 1 + PUSH 'key' + DICT_HAS +` + expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: true }) +}) + +test("DICT_HAS - checks key missing", async () => { + const str = ` + MAKE_DICT 0 + PUSH 'missing' + DICT_HAS +` + expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: false }) +})