As they say, life is what happens when you’re making other plans. But I’m back, so let’s talk some more about Runelang. In the interest of not dragging on months without finishing, we’re going to go ahead and push through the rest of the project. Onward!
So far, we’ve described the language, wrote a lexer, and parsed both nodes, parameters, and lists and more complicated expressions, literals, and an expression sublanguage.
Next up? Evaluation!
To do that, we need a few things:
An environment (so we can handle variables passed to function calls, along with a top level
define
syntax)evaluate*
functions for each kind of object we might have parsed (group, terminal, node, stacker, literal, list, … you get the idea)
Table of Contents
The environment
The goal of the environment is to properly handle lexical scope. If we have code like this:
define test(x) {
radial(scale: 1/8) [
text(x)
for x in 1..x
]
}
rune {
test(5)
}
It should work. We should be able to take 5
, assign it to x
when we call test
, and then within the radial, assign an inner x
the values 1 up to x = 5
.
So to do that, we’re going to store a series of objects, each of which can store their own data (as a map), and a pointer to the parent. If you get
/ lookup a value, look in the current environment. So long as you don’t find it, ask the parent, then it’s parent, and so on. Likewise, if you set a variable, start at the current scope and move up the ladder to set a value. That’s why the for x in 1..x
works. The outermost x
is being written to, but the x=5
passed into test
isn’t modified.
To make this all work:
class Environment {
constructor(parent = null) {
this.parent = parent
this.data = {}
}
set(key, value) {
this.data[key] = value
}
get(key) {
if (key in this.data) {
return this.data[key]
}
if (this.parent) {
return this.parent.get(key)
}
}
extend() {
return new Environment(this)
}
}
Not much, but it does what we need!
The root of evaluation
Okay, now that’s out of the way, let’s start with the base evaluation function evaluate
:
export default function evaluate(node, environment = null, asNext = false) {
let id = IDGenerator.next().value
// Construct base environment
if (environment === null) {
log.debug("Building base environment")
environment = new Environment()
for (let type in GLOBALS) {
for (let name in GLOBALS[type]) {
log.debug(`Loading global ${type}.${name}:\n---\n${GLOBALS[type][name].toString()}\n---`)
environment.set(name, { type, value: new Callable(GLOBALS[type][name]) })
log.debug("Resulting callable:", environment.get(name))
}
}
environment = environment.extend()
log.debug("Finished building base environment")
}
if (node === undefined || node === null) return null
// We've already evaluated this node (for example as a .next)
if (node.evaluated && !asNext) {
log.debug("Skipping evaluation, already evaluated", node)
return null
} else {
let formatNode = (node) => {
return JSON.stringify(node, (k, v) => (["body"].includes(k) ? "<removed>" : v))
}
let formatEnvironment = (environment) => {
if (environment.parent === null) {
return `<GLOBAL>`
} else {
return `<${JSON.stringify(environment.data)} extends ${formatEnvironment(environment.parent)}>`
}
}
log.info(`[${id}] Evaluating ${formatNode(node)} in ${formatEnvironment(environment)}`, node)
}
let result
// Evaluate a node group by evaluating each and concating results
if (node.type === "group") {
result = evaluateGroup(node, environment)
}
// Define a new callable in the environment
else if (node.type === "define") {
result = evaluateDefine(node, environment)
}
// Get the value for a node type
else if (node.type === "node") {
result = evaluateNode(node, environment)
}
// A parameter list
else if (node.type === "params") {
result = evaluateParams(node, environment)
}
// An expression
else if (node.type === "expression") {
result = evaluateExpression(node, environment)
}
// Different kinds of lists
else if (node.type === "list") {
result = evaluateList(node, environment)
}
// Get the value for a parameter list
else {
log.error("Runtime error, unknown node type to evaluate", node, node)
}
log.info(`[${id}] Returning: ${result}`)
return result
}
A few things this does:
Construct the base environment by defining all of the functions that are in the
GLOBALS
array (see the next section)Evaluate each node in turn. This is a bit tricky, since we have the syntax like this:
scale(0.9) circle
. That should be equivalent toscale(0.9) { circle }
, so we want to evaluate thecircle
as a child ofscale
and then not again as the next node in the list. That’s what thenode.evaluate && !asNext
chunk does.Debugging. That’s what
IDGenerator
is. In essence, it’s a JavaScript generator function that will return the next integer so we can keep track of each invocation when recurring.Dispatch to specific functions, such as
evaluateGroup
etc. We’ll come back to those.
Globals
As a quick aside, a discussion about how globals are handled. You can see the full code for them here, but the idea is something like this:
const GLOBALS = {
// Terminals do not have any children, they just generate content
terminal: {
line: (min = 0, max = 1) => `<line x1="0" y1="${100.0 * min}" x2="0" y2="${100.0 * max}" />`,
circle: () => '<circle cx="0" cy="0" r="100" />',
...
},
// Modifiers take a child and apply their changes to them
modifier: {
...
scale: (x, y) => (child) => `<g data-source="scale(${y ? [x, y] : x})" transform="scale(${x}, ${y ? y : x})">${child.eval()}</g>`,
fill: (color) => (child) => `<g data-source="fill(${color})" fill="${color}">${child.eval()}</g>`,
...
},
// Stackers apply to a list of children and organize them in various ways
stacker: {
stack: () => (children) => `<g data-source="stack()">${children.join(" ")}</g>`,
radial:
(scale = 1, offset = 1, rotate = false) =>
(children) => {
let points = onCircle(children.length)
let nodes = []
for (let i = 0; i < children.length; i++) {
let [x, y] = points[i]
x *= offset
y *= offset
let transforms = [`translate(${x}, ${y})`, `scale(${scale})`]
if (rotate) {
transforms.push(`rotate(${(360.0 * i) / children.length})`)
}
nodes.push(`<g data-source="radial(${scale}, ${offset}, ${rotate})" data-child="${i}" transform="${transforms.join(" ")}">${children[i]}</g>`)
}
return `<g data-source="radial(${scale}, ${offset}, ${rotate})">${nodes.join(" ")}</g>`
},
...
},
For each of the different kind of functions, you will have a different kind of inline function.
For terminals (with no children), they can take arbitrary parameters and render that to SVG. So line(0.5)
becomes a line from 0,0
to 50,0
(everything assumes a unit circle of 100 so the numbers don’t end up too tiny too fast), circle
becomes a unit circle.
For modifiers, you have base parameters (the same as terminals) first, then you have a single child node that you’re modifying. So something like scale(0.9) circle
would pass 0.9
as x
and circle
as the child node. It will render some wrapper (usually) and then evaluate the child in turn.
For stackers, you do the same thing, but this time with a list of children (either literal or with one of the list constructors). Either way, the contents of the child nodes will already have been evaluated at this point, but you can wrap them with all sorts of interesting things. For example, in the radial
above which will arrange the list of child nodes in a circle.
If you want any more complicated behavior, this is where you’d put it, but as I’ve mentioned before, if you just want to combine this primitives, you can do so with defines.
Turning globals (and other things) into callable objects
But these functions alone aren’t enough, we need a bit more magic to make them ‘callable’:
class Callable {
constructor(f) {
if (f === undefined) return
this.f = f
this.defaults = []
let match = f.toString().match(/\((.*?)\)/)
if (match === null) {
log.error(`could not find params in ${f.toString()}`, null, f)
}
for (let arg of match[1].split(",")) {
if (arg.includes("=")) {
let [key, def] = arg.split("=")
this.defaults.push([key.trim(), def.trim()])
} else {
this.defaults.push([arg.trim(), undefined])
}
}
}
call(params, environment) {
// this.defaults comes from the definition of the function
// params comes from the call
// params might contain zero, one, or both of args and kwargs
// params duplicates args in both args and kwargs, if it's in both it's passed as a kwarg
// argsToCall is what we'll send to the internal function with apply
let args = params?.args
let kwargs = params?.kwargs
let passedArgsCount = 0
if (args) passedArgsCount += args.length
if (kwargs) passedArgsCount -= Object.keys(kwargs).length
let argsToCall = []
for (let i = 0; i < this.defaults.length; i++) {
let [name, defaultValue] = this.defaults[i]
// Try to get each value from args, kwargs, then default
if (args && i < passedArgsCount) {
if (kwargs && args[i].asName && args[i].asName in kwargs) {
argsToCall.push(evaluate(kwargs[args[i].asName], environment))
} else {
argsToCall.push(evaluate(args[i], environment))
}
} else if (kwargs && name in kwargs) {
argsToCall.push(evaluate(kwargs[name], environment))
} else {
argsToCall.push(defaultValue)
}
}
return this.f.apply(this, argsToCall)
}
}
First, when we construct it, we’re going to dynamically pull in the parameter list. This really should be something that has a better way to do it built into JavaScript, but for better or for worse, we’re literally going to turn the function into a string of the original code (with toString
and use regex to parse the first parameter list, parsing that (looking for defaults)). It’s ugly, but it works.
After that, the call
function will take in any parameters plus the current environment and then use any defaults from above to fill in args that are missing before finally calling this.f.apply
to actually call the wrapped function.
This is… more complicated than I wish it was, but at least the user never really needs to use it. Just me!
Evaluating groups
Okay, first we have groups
. These are just collections of nodes that are being grouped together. For example, if you want something like scale(0.9) { circle star }
, you want both the circle
and the star
to be scaled, so you’ll evaluate the two as a group.
function evaluateGroup(node, environment) {
let children = []
for (let child of node.nodes) {
let result = evaluate(child, environment)
if (result !== null) children.push(result)
}
return children.join("")
}
Evaluating nodes
Next up, the three types of nodes: terminals, modifiers, and stackers. They all work closely together, but there are a few differences to be mindful of.
So first the dispatcher:
function evaluateNode(node, environment) {
/* Special case for includes */
// TODO: Better error handling
if (node.identifier.text === "include") {
let path = evaluate(node.params.args[0], environment)
return include(path, environment)
}
let object = environment.get(node.identifier.text)
if (!object) {
log.error(`object ${node.identifier.text} is not defined`, node)
}
if (object.type === "terminal") {
return evaluateTerminalNode(object, node, environment)
} else if (object.type == "modifier") {
return evaluateModifierNode(object, node, environment)
} else if (object.type == "stacker") {
return evaluateStackerNode(object, node, environment)
} else if (object.type == "group") {
return evaluateGroup(object, environment)
} else {
log.error("unknown object type", node, object)
}
}
This also handles the one special case I have: include
which only really works locally, but can include other files. Other than that, we’ll just go for one of the kinds we have below (or group, which we’ve already mentioned).
Terminal nodes
In this case, it’s just a straight function evaluation of the callable
as defined above / in the globals.
function evaluateTerminalNode(object, node, environment) {
if (node.body) {
log.error("terminal should not have a body", node)
}
if (node.group) {
log.error("terminal should not have a group", node)
}
return object.value.call(node.params, environment)
}
Modifier nodes
Here we want to find and pass along a child node. This could either be a literal child or group (if we have the {}
syntax) or just the next node in line if we don’t. A modifier always has a child though.
function evaluateModifierNode(object, node, environment) {
// If we have a group evaluate it as child, otherwise take the next node
let child = null,
asNext = false
if (node.body) {
if (node.body.type !== "group") {
log.error("modifiers can only modify groups", node)
}
child = node.body
} else if (node.next) {
child = node.next
asNext = true
node.next.evaluated = true
} else {
log.error("modifiers must have a following group or node", node)
}
child.eval = (env = null) => evaluate(child, env || environment, asNext)
return object.value.call(node.params, environment)(child)
}
In the end, we have the nested functions as I mentioned in globals. The first level is handled by the callable, but that will return the inner function that takes just the child.
So for this:
scale: (x, y) => (child) => `<g data-source="scale(${y ? [x, y] : x})" transform="scale(${x}, ${y ? y : x})">${child.eval()}</g>`,
The callable will parse the (x, y)
and that’s what object.value.call
will handle and return the second function: (child) => ...
. I hope that’s clear?
Stacker nodes
These work basically the same way as modifiers, except they can’t have a implicit child node, they always have to be given a list of some sort:
function evaluateStackerNode(object, node, environment) {
// Stackers apply to the following list
if (!node.list || node.body) {
log.error("stackers can only modify lists", node)
}
let children = evaluate(node.list, environment)
return object.value.call(node.params, environment)(children)
}
They still use the nested function approach though, this time passing first the call
parameters and the list of children
.
Evaluating lists
Okay, nodes are done, next up is the three kinds of lists: literal lists, for lists, and times lists. Each of them has slightly different behavior.
function evaluateList(node, environment) {
if (node.mode === "literal") {
let result = []
for (let childNode of node.nodes) {
result.push(evaluate(childNode, environment))
}
return result
} else if (node.mode === "for") {
if (node.variable.type !== "identifier") {
log.error(`for-list variable must be an identifier, got ${node.variable}`, node)
}
let variable = node.variable.text
let iterable = evaluate(node.expression, environment)
let forEnvironment = environment.extend()
log.debug(`In for-loop, iterable is ${iterable}`, node)
let result = []
for (let eachValue of iterable) {
log.debug(`In for-loop, setting ${variable} to ${eachValue}`, node)
forEnvironment.set(variable, eachValue)
result.push(evaluate(node.body, forEnvironment))
}
return result
} else if (node.mode === "times") {
let times = evaluate(node.expression, environment)
if (typeof times !== "number") {
log.error(`times-list must be a number, got ${times}`, node)
}
let result = []
for (var i = 0; i < times; i++) {
result.push(evaluate(node.body, environment))
}
return result
} else {
log.error(`unknown list mode ${node.mode}`, node)
}
}
The literal list is basically the same thing as a group
, but the difference comes in what you can pass them to. groups
can be passed to modifiers
(or used directly), while lists
are passed to stackers
. I’m not sure if the distinction is 100% necessary, but it did make it easier to reason about.
For for
lists we’re going to extend the environment, since in each case, we’re going to define a new variable, scoped just to the evaluation of the list. To do that, we’ll evaluate the range-like object (see expressions
below) and assign each value in turn as the variable
and then evaluate the node
with that value.
For the times
list, we do the same (evaluating the same body many times), but this time without a new variable/environment. Mostly useful for copying the same thing a few times without having to ignore a for
loop and constructing a range every time.
Evaluating expressions
Now here’s one of the big (and honestly more alien) bits of the code: expressions. Really, it’s a language within a language, since at this point, we have an RPN expression to evaluate.
function evaluateExpression(node, environment) {
log.debug(`Evaluating RPN`, node, node.rpn)
let stack = []
for (let el of node.rpn) {
log.debug(`In RPN: Current stack`, node, { el, stack })
if (el.type === "literal") {
stack.push(el.value)
} else if (el.type === "operator") {
if (stack.length < 2) {
log.error(`In RPN: tried to evaluate ${el} but needed two parameters`, node, { el, stack })
}
let right = stack.pop()
let left = stack.pop()
let f = EVAL_OPERATORS[el.value].f
stack.push(f(left, right))
} else if (el.type === "variable") {
let key = el.value
let value = environment.get(el.value)
if (value !== undefined && value !== null) {
stack.push(value)
} else {
log.error(`In RPN: failed to evaluate variable ${key} not defined`, node, { el, stack })
}
} else if (el.type === "function") {
let args = []
for (var i = 0; i < el.arity; i++) {
args.unshift(stack.pop())
}
let result = el.value.apply(null, args)
stack.push(result)
} else {
log.error(`In RPN: unknown type ${el}`, node, { el, stack })
}
}
if (stack.length !== 1) {
log.error(`In RPN: expressions must result in exactly one value, got ${stack}`, node, { el, stack })
}
return stack[0]
}
Really, the wikipedia article above does a good job explaining what’s going on here, but the basic idea is this: we either have a literal–which we push onto a stack–or a function with a known arity. For functions, we pull as many params as we need off the stack, apply the function, and then push the result back onto the stack.
Really, most of the work here was done in parsing, but we do have a few built in operators in the same constants.js function mentioned earlier. Specifically, things like chooseOne
and chooseMany
can take 1 or 2 parameters respectively. And you can easily add more functions here to do all sorts of things. Randomness? Loading external data? External API access? Who knows!
Evaluating defines
Okay, last, but certainly not least, we have evaluateDefine
. This is a special syntax that lets you define your very own terminal
or modifier
. Unfortunately, I haven’t yet figured out how to define custom stackers
yet, but we’ll see if we can yet.
function evaluateDefine(node, environment) {
let name = node.identifier.text
if (environment.get(name)) {
log.error(`unable to redefine ${name}`, node.location, node)
}
let defaults = []
if (node.params) {
for (var i = 0; i < node.params.args.length; i++) {
let name = node.params.args[i].asName
if (!name) {
log.error(`Unable to define ${name}, missing a name in args`, node.location, node)
}
// This will be undefined if no default is set, this is fine
let def = evaluate(node.params.kwargs[name], environment)
defaults.push([name, def])
}
}
let definedFunction
if (node.mode === "terminal") {
// Create the new function for the callable object
definedFunction = (...args) => {
// Bind passed variables into the local environment
let definedEnvironment = environment.extend()
for (let i = 0; i < defaults.length; i++) {
let [key, def] = defaults[i]
let value = args[i]
if (value === undefined) value = def
definedEnvironment.set(key, value)
}
// Call the body (a single group) with those args/kwargs
return evaluate(node.body, definedEnvironment)
}
} else if (node.mode === "modifier") {
// Create the new function for the callable object
definedFunction =
(...args) =>
(child) => {
// Bind passed variables into the local environment
let definedEnvironment = environment.extend()
for (let i = 0; i < defaults.length; i++) {
let [key, def] = defaults[i]
let value = args[i]
if (value === undefined) value = def
definedEnvironment.set(key, value)
}
definedEnvironment.set(node.child, child)
// Call the body (a single group) with those args/kwargs
return evaluate(node.body, definedEnvironment)
}
} else if (node.mode === "stacker") {
throw "define stacker not implemented"
} else {
log.error(`unknown define mode ${node.mode}`, node.location, node)
}
log.awesome(`Created a new function ${name} of type ${node.mode} with defaults = ${defaults}`, node)
let callable = new Callable()
callable.defaults = defaults
callable.f = definedFunction
let obj = { type: node.mode, value: callable }
environment.set(name, obj)
}
Essentially, we have to:
Evaluate the parameters that we’re being passed in parens
Figure out what kind of node we’re defining based on if we have a group or list (or neither) following it
Define a custom function that will take in the parameters from above and return something that can be passed to
Callable
and work like the native functions we haveExtend the current environment with the newly defined function (you can actually define local functions within other functions and it will work as it ‘should’)
It’s a neat bit of code if I do say so myself!
Summary
And… that’s it. It’s a bit of a long writeup and I probably could have gone into more detail, but I hope anything I missed in the writeup was in the comments or the code and anything I missed there… well let me know. Always happy to chat!
Demo
It’s something that I always wanted to do with my older rune DSL but couldn’t make work. Now I did!
(Fully functional, give it a try and see what you can make!)
Output
Source
Log (most recent messages first):
Next steps
I think that’s enough for this code for now and I’m looking forward to other things, but there are two things that I still want to try:
a better looking stand alone editor for Runelang (that still runs in a browser) rather than the semi hacky demo above
a full CLI release with examples so you can download and run it yourself
Look for these soon! (At the very least hopefully not months more…)