Yes, absolutely. This is the defining superpower of this architecture. Because
the format is homoiconicmeaning the code structure and the data structure are
identical (JSON arrays)inserting, removing, or combining programs requires
zero string parsing.
You do not need to manipulate source text; you just use standard JavaScript
array operations (push, splice, concat, or the spread operator) to manipulate
the AST before or during compilation.
Here is how you can handle both "by copy" and "by reference" insertions.
1. By Copy (AST Composition & Macros)
Because the AST is just data, you can dynamically wrap, inject, or rewrite
programs inside your host environment (Node.js) before feeding them to the
compile() function. This is effectively a Macro system.
Imagine you have a core math program, but you want to dynamically wrap it in a
telemetry or configuration block:
/ The core logic (could be loaded from a database or another file)
const coreLogic = ["*", "price", 0.85];
/ Dynamically composing a larger program by copying/embedding the core logic
const fullProgram = [
"let", [
["price", 100]
],
coreLogic // Inserted by value/copy
];
/ fullProgram is now: ["let", [["price", 100]], ["*", "price", 0.85]]
compile(fullProgram);
To remove or alter nodes, you can run a simple recursive map over the array.
For example, if you wanted to find every instance of the * operator and
replace it with a custom safe_multiply function, you just traverse the arrays
and swap the strings before compiling.
2. By Reference (Dynamic Imports / Includes)
If you want to maintain a single source of truth for a shared library (e.g.,
an error-handling module) and include it by reference, you can add a
pre-processing step to the compiler or introduce an include directive.
The Pre-processor Approach:
Before compiling, you recursively scan the AST for a custom ["import",
"module_name"] node. When found, you fetch the referenced AST and splice it
into the array in place.
/ A library stored in memory or a database
const stdlib = {
"math_utils": [
"def", "square", ["x"], ["*", "x", "x"]
]
};
function preProcess(ast) {
if (!Array.isArray(ast)) return ast;
// If it's an import node, swap it out for the referenced AST
if (ast[0] === 'import') {
const moduleName = ast[1];
return stdlib[moduleName];
}
// Otherwise, recursively process children
return ast.map(node => preProcess(node));
}
/ User's program:
const rawProgram = [
"let", [],
["import", "math_utils"], // Reference to external code
["square", 5]
];
/ Pre-process expands the reference, then compiles
const expandedAST = preProcess(rawProgram);
const bytecode = compile(expandedAST);
3. Inserting at Runtime (The eval approach)
If you need the VM to generate and execute new ASTs dynamically while the
program is running, you can add an EVAL opcode.
Because the compiler and VM are in the same environment, the EVAL opcode would
simply pop an array off the data stack, run compile() on it to generate a new
Code Object, and push a new Call Frame onto the Call Stack on the fly.
This allows your JSON-Lisp program to construct arrays using list-manipulation
functions, and then execute those constructed arrays as code.
Architectural Benefit
If you are handling multiple test suites or routing APIs, this makes payload
generation incredibly lightweight. A client can send a small JSON array
representing a query, and your backend gateway can insert that array directly
into a larger, pre-validated AST wrapper (handling auth checks, rate limits,
etc.) before compiling it to bytecode and executing it.