add... a lot

This commit is contained in:
Chris Wanstrath 2025-10-05 15:21:51 -07:00
parent d6aec409f0
commit 499584c5fe
8 changed files with 868 additions and 90 deletions

View File

@ -9,52 +9,92 @@ It's where Shrimp live.
## TODO (tests) ## TODO (tests)
- [ ] PUSH ### Stack Operations
- [ ] POP - [x] PUSH
- [ ] DUP - [x] POP
- [x] DUP
- [ ] LOAD ### Variables
- [ ] STORE - [x] LOAD
- [x] STORE
### Arithmetic
- [x] ADD - [x] ADD
- [x] SUB - [x] SUB
- [x] MUL - [x] MUL
- [x] DIV - [x] DIV
- [ ] MOD - [x] MOD
- [ ] EQ
- [ ] NEQ
- [ ] LT
- [ ] GT
- [ ] LTE
- [ ] GTE
- [ ] AND
- [ ] OR
- [ ] NOT
- [ ] JUMP ### Comparison
- [ ] JUMP_IF_FALSE - [x] EQ
- [ ] JUMP_IF_TRUE - [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 - [ ] BREAK
- [ ] CONTINUE - [ ] CONTINUE
### Exception Handling
- [ ] PUSH_TRY - [ ] PUSH_TRY
- [ ] POP_TRY - [ ] POP_TRY
- [ ] THROW - [ ] THROW
### Functions
- [ ] MAKE_FUNCTION - [ ] MAKE_FUNCTION
- [ ] CALL - [ ] CALL
- [ ] TAIL_CALL - [ ] TAIL_CALL
- [ ] CALL_TYPESCRIPT
- [ ] RETURN - [ ] RETURN
- [ ] MAKE_ARRAY ### Arrays
- [ ] ARRAY_GET - [x] MAKE_ARRAY
- [ ] ARRAY_SET - [x] ARRAY_GET
- [ ] ARRAY_LEN - [x] ARRAY_SET
- [x] ARRAY_LEN
- [ ] MAKE_DICT ### Dictionaries
- [ ] DICT_GET - [x] MAKE_DICT
- [ ] DICT_SET - [x] DICT_GET
- [ ] DICT_HAS - [x] DICT_SET
- [x] DICT_HAS
- [ ] HALT ### 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)

101
SPEC.md
View File

@ -36,10 +36,10 @@ type Value =
| { type: 'boolean', value: boolean } | { type: 'boolean', value: boolean }
| { type: 'number', value: number } | { type: 'number', value: number }
| { type: 'string', value: string } | { type: 'string', value: string }
| { type: 'array', items: Value[] } | { type: 'array', value: Value[] }
| { type: 'dict', entries: Map<string, Value> } | { type: 'dict', value: Map<string, Value> }
| { type: 'function', params: string[], defaults: Record<string, Value>, | { type: 'function', params: string[], defaults: Record<string, Value>,
body: number, scope: Scope, variadic: boolean, kwargs: boolean } body: number, parentScope: Scope, variadic: boolean, kwargs: boolean }
``` ```
### Type Coercion ### Type Coercion
@ -49,8 +49,7 @@ type Value =
**toString**: string → identity, number → string, boolean → string, null → "null", **toString**: string → identity, number → string, boolean → string, null → "null",
function → "<function>", array → "[item, item]", dict → "{key: value, ...}" function → "<function>", array → "[item, item]", dict → "{key: value, ...}"
**isTruthy**: boolean → value, number → value !== 0, string → value !== "", **isTrue**: Only `null` and `false` are falsy. Everything else (including `0`, `""`, empty arrays, empty dicts) is truthy.
null → false, array → length > 0, dict → size > 0, others → true
## Bytecode Format ## Bytecode Format
@ -169,53 +168,73 @@ All arithmetic operations pop two values, perform operation, push result as numb
### Comparison Operations ### 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 #### EQ
**Stack**: [a, b] → [a == b ? 1 : 0] **Stack**: [a, b] → [boolean]
**Note**: Type-aware equality **Note**: Type-aware equality (deep comparison for arrays/dicts)
#### NEQ #### NEQ
**Stack**: [a, b] → [a != b ? 1 : 0] **Stack**: [a, b] → [boolean]
#### LT #### LT
**Stack**: [a, b] → [a < b ? 1 : 0] **Stack**: [a, b] → [boolean]
**Note**: Numeric comparison (values coerced to numbers)
#### GT #### GT
**Stack**: [a, b] → [a > b ? 1 : 0] **Stack**: [a, b] → [boolean]
**Note**: Numeric comparison (values coerced to numbers)
#### LTE #### LTE
**Stack**: [a, b] → [a <= b ? 1 : 0] **Stack**: [a, b] → [boolean]
**Note**: Numeric comparison (values coerced to numbers)
#### GTE #### GTE
**Stack**: [a, b] → [a >= b ? 1 : 0] **Stack**: [a, b] → [boolean]
**Note**: Numeric comparison (values coerced to numbers)
### Logical Operations ### Logical Operations
#### AND
**Stack**: [a, b] → [isTruthy(a) && isTruthy(b) ? 1 : 0]
#### OR
**Stack**: [a, b] → [isTruthy(a) || isTruthy(b) ? 1 : 0]
#### NOT #### 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):
```
<evaluate left>
DUP
JUMP_IF_FALSE 2 # skip POP and <evaluate right>
POP
<evaluate right>
end:
```
**OR pattern** (short-circuits if left side is true):
```
<evaluate left>
DUP
JUMP_IF_TRUE 2 # skip POP and <evaluate right>
POP
<evaluate right>
end:
```
### Control Flow ### Control Flow
#### JUMP #### JUMP
**Operand**: Instruction address (number) **Operand**: Offset (number)
**Effect**: Set PC to address **Effect**: Add offset to PC (relative jump)
**Stack**: No change **Stack**: No change
#### JUMP_IF_FALSE #### JUMP_IF_FALSE
**Operand**: Instruction address (number) **Operand**: Offset (number)
**Effect**: If top of stack is falsy, jump to address **Effect**: If top of stack is falsy, add offset to PC (relative jump)
**Stack**: [condition] → [] **Stack**: [condition] → []
#### JUMP_IF_TRUE #### JUMP_IF_TRUE
**Operand**: Instruction address (number) **Operand**: Offset (number)
**Effect**: If top of stack is truthy, jump to address **Effect**: If top of stack is truthy, add offset to PC (relative jump)
**Stack**: [condition] → [] **Stack**: [condition] → []
#### BREAK #### BREAK
@ -441,21 +460,19 @@ type TypeScriptFunction = (...args: Value[]) => Promise<Value> | Value;
LOAD 'x' LOAD 'x'
PUSH 5 PUSH 5
GT GT
JUMP_IF_FALSE else_label JUMP_IF_FALSE 2 # skip then block, jump to else
# then block # then block (N instructions)
JUMP end_label JUMP M # skip else block
else_label:
# else block # else block
end_label:
``` ```
### While Loop ### While Loop
``` ```
loop_start: loop_start:
# condition # condition
JUMP_IF_FALSE loop_end JUMP_IF_FALSE N # jump past loop body
# body # body (N-1 instructions)
JUMP loop_start JUMP -N # jump back to loop_start
loop_end: loop_end:
``` ```
@ -463,19 +480,19 @@ loop_end:
``` ```
MAKE_FUNCTION <index> MAKE_FUNCTION <index>
STORE 'functionName' STORE 'functionName'
JUMP skip_body JUMP N # skip function body
function_body: function_body:
# function code # function code (N instructions)
RETURN RETURN
skip_body: skip_body:
``` ```
### Try-Catch ### Try-Catch
``` ```
PUSH_TRY catch_label PUSH_TRY N # catch is N instructions ahead
# try block # try block
POP_TRY POP_TRY
JUMP end_label JUMP M # skip catch block
catch_label: catch_label:
STORE 'errorVar' # Error is on stack STORE 'errorVar' # Error is on stack
# catch block # catch block
@ -495,12 +512,12 @@ CALL { positional: 1, named: 1 }
``` ```
MAKE_FUNCTION <factorial_def> MAKE_FUNCTION <factorial_def>
STORE 'factorial' STORE 'factorial'
JUMP main JUMP 10 # skip to main
factorial_body: factorial_body:
LOAD 'n' LOAD 'n'
PUSH 0 PUSH 0
EQ EQ
JUMP_IF_FALSE recurse JUMP_IF_FALSE 2 # skip to recurse
LOAD 'acc' LOAD 'acc'
RETURN RETURN
recurse: recurse:
@ -511,7 +528,7 @@ recurse:
LOAD 'n' LOAD 'n'
LOAD 'acc' LOAD 'acc'
MUL MUL
TAIL_CALL 2 # No stack growth! TAIL_CALL 2 # No stack growth!
main: main:
LOAD 'factorial' LOAD 'factorial'
PUSH 5 PUSH 5
@ -624,7 +641,7 @@ const result = await vm.execute()
## Notes ## Notes
- PC increment happens after each instruction execution - 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 - All async operations (TypeScript functions) must be awaited
- Arrays and dicts are mutable (pass by reference) - Arrays and dicts are mutable (pass by reference)
- Functions are immutable values - Functions are immutable values

View File

@ -15,6 +15,10 @@ export type Constant =
| Value | Value
| FunctionDef | 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 */ { export function toBytecode(str: string): Bytecode /* throws */ {
const lines = str.trim().split("\n") const lines = str.trim().split("\n")
@ -25,20 +29,39 @@ export function toBytecode(str: string): Bytecode /* throws */ {
for (let line of lines) { for (let line of lines) {
let [op, operand] = line.trim().split(" ") let [op, operand] = line.trim().split(" ")
const opCode = OpCode[op as keyof typeof OpCode]
let operandValue: number | string | undefined = undefined
if (operand) { if (operand) {
if (/^\d+/.test(operand)) { // Variable names for LOAD, STORE, CALL_TYPESCRIPT
bytecode.constants.push(toValue(parseFloat(operand))) if (opsWithVarNames.has(opCode)) {
} else if (/^['"]\w+/.test(operand)) { operandValue = operand
bytecode.constants.push(toValue(operand.slice(1, operand.length - 1))) }
} else { // Direct addresses for JUMP operations
throw `Unknown operand: ${operand}` 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({ bytecode.instructions.push({
op: OpCode[op as keyof typeof OpCode], op: opCode,
operand: operand ? bytecode.constants.length - 1 : undefined operand: operandValue
}) })
} }

View File

@ -4,9 +4,61 @@ export enum OpCode {
POP, // operand: none POP, // operand: none
DUP, // operand: none DUP, // operand: none
// variables
LOAD, // operand: variable name (string)
STORE, // operand: variable name (string)
// math // math
ADD, ADD,
SUB, SUB,
MUL, 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
} }

View File

@ -8,14 +8,14 @@ export class Scope {
this.parent = parent this.parent = parent
} }
get(name: string): Value /* throws */ { get(name: string): Value | undefined {
if (this.locals.has(name)) if (this.locals.has(name))
return this.locals.get(name)! return this.locals.get(name)!
if (this.parent) if (this.parent)
return this.parent.get(name) return this.parent.get(name)
throw new Error(`Undefined variable: ${name}`) return undefined
} }
set(name: string, value: Value) { set(name: string, value: Value) {

View File

@ -57,5 +57,66 @@ export function toValue(v: any): Value /* throws */ {
} }
export function toNumber(v: Value): number { 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 '<function>'
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
}
} }

156
src/vm.ts
View File

@ -3,7 +3,7 @@ import type { ExceptionHandler } from "./exception"
import type { Frame } from "./frame" import type { Frame } from "./frame"
import { OpCode } from "./opcode" import { OpCode } from "./opcode"
import { Scope } from "./scope" import { Scope } from "./scope"
import { type Value, toValue, toNumber } from "./value" import { type Value, toValue, toNumber, isTrue, isEqual, toString } from "./value"
export class VM { export class VM {
pc = 0 pc = 0
@ -37,11 +37,11 @@ export class VM {
async execute(instruction: Instruction) /* throws */ { async execute(instruction: Instruction) /* throws */ {
switch (instruction.op) { switch (instruction.op) {
case OpCode.PUSH: case OpCode.PUSH:
const idx = instruction.operand as number const constIdx = instruction.operand as number
const constant = this.constants[idx] const constant = this.constants[constIdx]
if (!constant || constant.type === 'function_def') if (!constant || constant.type === 'function_def')
throw new Error(`Invalid constant index: ${idx}`) throw new Error(`Invalid constant index: ${constIdx}`)
this.stack.push(constant) this.stack.push(constant)
break break
@ -70,6 +70,147 @@ export class VM {
this.binaryOp((a, b) => toNumber(a) / toNumber(b)) this.binaryOp((a, b) => toNumber(a) / toNumber(b))
break 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<string, Value>()
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: default:
throw `Unknown op: ${instruction.op}` throw `Unknown op: ${instruction.op}`
} }
@ -81,4 +222,11 @@ export class VM {
const result = fn(a, b) const result = fn(a, b)
this.stack.push({ type: 'number', value: result }) 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 })
}
} }

View File

@ -2,7 +2,7 @@ import { test, expect } from "bun:test"
import { run } from "#index" import { run } from "#index"
import { toBytecode } from "#bytecode" import { toBytecode } from "#bytecode"
test("adding numbers", async () => { test("ADD - add two numbers", async () => {
const str = ` const str = `
PUSH 1 PUSH 1
PUSH 5 PUSH 5
@ -18,7 +18,7 @@ test("adding numbers", async () => {
expect(await run(toBytecode(str2))).toEqual({ type: 'number', value: 600 }) expect(await run(toBytecode(str2))).toEqual({ type: 'number', value: 600 })
}) })
test("subtracting numbers", async () => { test("SUB - subtract two numbers", async () => {
const str = ` const str = `
PUSH 5 PUSH 5
PUSH 2 PUSH 2
@ -27,7 +27,7 @@ test("subtracting numbers", async () => {
expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 3 }) expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 3 })
}) })
test("multiplying numbers", async () => { test("MUL - multiply two numbers", async () => {
const str = ` const str = `
PUSH 5 PUSH 5
PUSH 2 PUSH 2
@ -36,7 +36,7 @@ test("multiplying numbers", async () => {
expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 10 }) expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 10 })
}) })
test("dividing numbers", async () => { test("DIV - divide two numbers", async () => {
const str = ` const str = `
PUSH 10 PUSH 10
PUSH 2 PUSH 2
@ -52,3 +52,440 @@ test("dividing numbers", async () => {
expect(await run(toBytecode(str2))).toEqual({ type: 'number', value: Infinity }) 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 && <anything> 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 })
})