To support Pascals fixed arrays and records (structs/objects) dynamically, we
must introduce memory-addressing opcodes.
This also gives us the perfect opportunity to address a subtle stack machine
design pattern: Assignments as Expressions. In C, Pascal, and JavaScript, x =
5 is an expression that evaluates to 5. If we make our OP_SET_IDX (and our
existing OP_SET) push the assigned value back onto the data stack, it plays
perfectly with our do block's POP cleanup phase, guaranteeing the data stack
never misaligns.
Here is how we add structured Arrays, Records, and Index Mutability to the
engine.
1. Expanding the ISA
We add opcodes to create arrays/records from the stack, get by index/key, and
set by index/key.
const Opcodes = Object.assign(Opcodes || {}, {
GET_IDX: 'OP_GET_IDX', // Pops Key, Target -> Pushes Target[Key]
SET_IDX: 'OP_SET_IDX', // Pops Value, Key, Target -> Mutates, Pushes Value
RECORD: 'OP_RECORD', // Pops N pairs -> Pushes Object
ARRAY: 'OP_ARRAY' // Pops N items -> Pushes Array
});
2. The Compiler: Memory Structures
We add four new special forms to our compile function.
// 1. Array Creation: ["array", 1, 2, ["+", 1, 2]]
if (op === 'array') {
args.forEach(arg => compile(arg, bytecode, options));
bytecode.push({ op: Opcodes.ARRAY, val: args.length });
return bytecode;
}
// 2. Record Creation: ["record", "name", "John", "age", 30]
if (op === 'record') {
if (args.length % 2 !== 0) throw new Error("Record requires even number of
arguments (key-value pairs)");
for (let i = 0; i < args.length; i += 2) {
compile(args[i], bytecode, options); // Push Key
compile(args[i + 1], bytecode, options); // Push Value
}
bytecode.push({ op: Opcodes.RECORD, val: args.length / 2 });
return bytecode;
}
// 3. Property Access: ["get", "user", "name"]
if (op === 'get') {
compile(args[0], bytecode, options); // Push Target
compile(args[1], bytecode, options); // Push Key
bytecode.push({ op: Opcodes.GET_IDX });
return bytecode;
}
// 4. Property Mutation: ["set-idx", "user", "age", 31]
if (op === 'set-idx') {
compile(args[0], bytecode, options); // Push Target
compile(args[1], bytecode, options); // Push Key
compile(args[2], bytecode, options); // Push Value
bytecode.push({ op: Opcodes.SET_IDX });
return bytecode;
}
(Note: We should also update the compiler's set block and the VM's OP_SET to
ensure they also push the value back, keeping all assignment behaviors
uniform).
3. The VM: Dynamic Memory Access
In the worker thread, we handle the instantiation and mutation of these memory
structures using the data stack.
// --- RECORD & ARRAY CREATION ---
case Opcodes.ARRAY: {
const arrCount = inst.val;
const arr = new Array(arrCount);
// Pop in reverse order to maintain correct array sequence
for (let i = arrCount - 1; i >= 0; i--) {
arr[i] = dataStack.pop();
}
dataStack.push(arr);
break;
}
case Opcodes.RECORD: {
const pairCount = inst.val;
const record = {};
// Pop pairs in reverse order
for (let i = 0; i < pairCount; i++) {
const value = dataStack.pop();
const key = dataStack.pop();
record[key] = value;
}
dataStack.push(record);
break;
}
// --- MEMORY ACCESS ---
case Opcodes.GET_IDX: {
const key = dataStack.pop();
const target = dataStack.pop();
if (target === null || target === undefined) {
throw new TypeError(`Cannot read property '${key}' of ${target}`);
}
dataStack.push(target[key]);
break;
}
case Opcodes.SET_IDX: {
const value = dataStack.pop();
const key = dataStack.pop();
const target = dataStack.pop();
if (target === null || typeof target !== 'object') {
throw new TypeError(`Cannot assign property '${key}' to
non-object`);
}
target[key] = value;
// PUSH VALUE BACK! This ensures `do` blocks can safely `POP`
intermediate assignments
// without accidentally popping other critical variables off the data
stack.
dataStack.push(value);
break;
}
Writing Pascal-Style Data Structures in JSON-Lisp
With this implementation, you can define structured configurations, manipulate
them dynamically, and pass them by reference (since JavaScript objects act as
pointers in the closure environments).
Here is what a payload creating and mutating a user profile looks like:
[
"let", [
[
"user",
["record",
"name", "Alice",
"role", "admin",
"scores", ["array", 100, 95, 80]
]
]
],
[
"do",
["set-idx", "user", "role", "superadmin"],
["set-idx", ["get", "user", "scores"], 2, 99],
"user"
]
]
Because set-idx cleanly evaluates to the value, the intermediate statements in
the do block are perfectly discarded by the compiler's OP_POP cleanup, leaving
only the mutated "user" object as the final return value for the main thread.