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)
- [ ] 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
### 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: 'number', value: number }
| { type: 'string', value: string }
| { type: 'array', items: Value[] }
| { type: 'dict', entries: Map<string, Value> }
| { type: 'array', value: Value[] }
| { type: 'dict', value: Map<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
@ -49,8 +49,7 @@ type Value =
**toString**: string → identity, number → string, boolean → string, null → "null",
function → "<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):
```
<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
#### 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> | 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 <index>
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 <factorial_def>
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

View File

@ -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
})
}

View File

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

View File

@ -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) {

View File

@ -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 '<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 { 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<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:
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 })
}
}

View File

@ -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 && <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 })
})